"""Optional mDNS advertisement for WESP HTTP (zeroconf).""" from __future__ import annotations import atexit import logging import os import socket import threading import time from typing import Any, Optional, Tuple from app.services.lan_network import detect_lan_ip, parse_wesp_listen _log = logging.getLogger(__name__) _lock = threading.Lock() _zeroconf: Any = None _service_info: Any = None _active = False _last_error: Optional[str] = None _registered_key: Optional[Tuple[str, str, int]] = None _last_logged_error: Optional[str] = None _mdns_shutdown_registered = False _MDNS_REGISTER_TIMEOUT_SEC = 15.0 try: from zeroconf import IPVersion, ServiceInfo, Zeroconf _ZEROCONF_AVAILABLE = True except ImportError: IPVersion = None # type: ignore[misc, assignment] ServiceInfo = None # type: ignore[misc, assignment] Zeroconf = None # type: ignore[misc, assignment] _ZEROCONF_AVAILABLE = False def mdns_available() -> bool: return _ZEROCONF_AVAILABLE def mdns_status() -> dict: with _lock: return { "mdns_available": _ZEROCONF_AVAILABLE, "mdns_active": bool(_active), "mdns_error": _last_error, } def _registration_key(hostname: str, lan_ip: str, port: int) -> Tuple[str, str, int]: return (hostname, lan_ip, int(port)) def _hostname_to_server_fqdn(local_hostname: str) -> str: name = (local_hostname or "").strip().rstrip(".") if not name: return "" if not name.lower().endswith(".local"): name = f"{name}.local" return f"{name}." def _instance_label(local_hostname: str) -> str: name = (local_hostname or "").strip().rstrip(".") if name.lower().endswith(".local"): name = name[: -len(".local")] return name or "wesp" def _log_mdns_error_if_changed(app, message: str) -> None: global _last_logged_error msg = (message or "").strip() or "неизвестная ошибка mDNS" if msg == _last_logged_error: return _last_logged_error = msg if app.config.get("TESTING"): return from app.services.network_activity_log import log_mdns_error log_mdns_error(app, message=msg) def stop_mdns(app=None, *, log_event: bool = False, reason: str = "") -> None: global _zeroconf, _service_info, _active, _registered_key was_active = False with _lock: was_active = bool(_active) zc = _zeroconf info = _service_info _zeroconf = None _service_info = None _active = False _registered_key = None if zc is not None and info is not None: try: zc.unregister_service(info) except Exception: _log.debug("mdns unregister_service failed", exc_info=True) try: zc.close() except Exception: _log.debug("mdns zeroconf close failed", exc_info=True) if app is not None and was_active and log_event: from app.services.network_activity_log import log_mdns_stopped log_mdns_stopped(app, reason=reason or "остановлен") def _register_service_with_timeout(zc: Any, info: Any, *, timeout_sec: float) -> None: """register_service в отдельном потоке — на macOS/Bonjour иногда зависает без ответа.""" exc_holder: list[BaseException | None] = [None] def _worker() -> None: try: zc.register_service(info) except BaseException as exc: exc_holder[0] = exc thread = threading.Thread(target=_worker, daemon=True, name="wesp-mdns-register") thread.start() thread.join(timeout=max(1.0, float(timeout_sec))) if thread.is_alive(): try: zc.close() except Exception: _log.debug("mdns zeroconf close after register timeout", exc_info=True) raise TimeoutError(f"mDNS register_service не ответил за {timeout_sec}s") if exc_holder[0] is not None: raise exc_holder[0] def start_mdns(app) -> None: global _zeroconf, _service_info, _active, _last_error, _registered_key, _last_logged_error if not _ZEROCONF_AVAILABLE: with _lock: _active = False _last_error = "zeroconf не установлен" _log_mdns_error_if_changed(app, _last_error) return cfg = app.config if not bool(cfg.get("WESP_MDNS_ENABLED", False)): stop_mdns(app, log_event=True, reason="выключено в настройках") with _lock: _last_error = None _last_logged_error = None return local_hostname = str(cfg.get("WESP_NETWORK_LOCAL_HOSTNAME") or "").strip() if not local_hostname: stop_mdns(app, log_event=True, reason="local_hostname не задан") with _lock: _last_error = "local_hostname не задан" _log_mdns_error_if_changed(app, _last_error) return listen_host, listen_port = parse_wesp_listen(str(cfg.get("WESP_LISTEN") or "")) lan_ip = detect_lan_ip(use_cache=False) if not lan_ip and listen_host not in ("0.0.0.0", "::", ""): lan_ip = listen_host if listen_host not in ("127.0.0.1", "localhost") else "" if not lan_ip: stop_mdns(app, log_event=True, reason="LAN IP не определён") with _lock: _last_error = "LAN IP не определён" _log_mdns_error_if_changed(app, _last_error) return key = _registration_key(local_hostname, lan_ip, listen_port) with _lock: if _active and _registered_key == key: _last_error = None return server_fqdn = _hostname_to_server_fqdn(local_hostname) instance = _instance_label(local_hostname) try: addresses = [socket.inet_aton(lan_ip)] except OSError as exc: stop_mdns(app, log_event=True, reason=str(exc)) with _lock: _last_error = str(exc) _log_mdns_error_if_changed(app, _last_error) return stop_mdns(app, log_event=False) try: zc = Zeroconf(ip_version=IPVersion.V4Only) info = ServiceInfo( "_http._tcp.local.", f"{instance}._http._tcp.local.", addresses=addresses, port=int(listen_port), properties={b"path": b"/"}, server=server_fqdn, ) _register_service_with_timeout(zc, info, timeout_sec=_MDNS_REGISTER_TIMEOUT_SEC) with _lock: _zeroconf = zc _service_info = info _active = True _registered_key = key _last_error = None _last_logged_error = None _log.info("mDNS: %s → %s:%s", server_fqdn, lan_ip, listen_port) if not app.config.get("TESTING"): from app.services.network_activity_log import log_mdns_started log_mdns_started( app, hostname=local_hostname, lan_ip=lan_ip, port=int(listen_port), ) except Exception as exc: with _lock: _active = False _registered_key = None _last_error = str(exc) _log.warning("mDNS start failed: %s", exc) _log_mdns_error_if_changed(app, str(exc)) def reload_mdns(app) -> None: with app.app_context(): start_mdns(app) if not mdns_status().get("mdns_active"): schedule_mdns_retry_if_needed(app) def mdns_skip_reason_on_boot(app) -> Optional[str]: """Причина пропуска autostart или None, если можно стартовать.""" if app.config.get("TESTING"): return "TESTING" # migrations/env.py поднимает create_app до upgrade — zeroconf + SQLite DDL на Pi давали segfault. if os.environ.get("WESP_ALEMBIC", "").strip() == "1": return "WESP_ALEMBIC" if not app.config.get("WESP_MDNS_ENABLED"): return "mDNS выключен в настройках" if app.debug and os.environ.get("WERKZEUG_RUN_MAIN") != "true": return "Flask reloader (родитель)" return None def should_start_mdns_on_boot(app) -> bool: """Не дублировать mDNS в родительском процессе Flask debug reloader.""" return mdns_skip_reason_on_boot(app) is None def ensure_mdns_running(app, *, source: str = "boot") -> bool: """Старт mDNS с логом причины пропуска; True если сервис активен после вызова.""" reason = mdns_skip_reason_on_boot(app) if reason: if reason != "Flask reloader (родитель)": _log.info("mDNS autostart пропущен (%s): %s", source, reason) return False with app.app_context(): start_mdns(app) return mdns_status().get("mdns_active") is True def _mdns_retry_thread_running() -> bool: return any( t.name == "wesp-mdns-retry" and t.is_alive() for t in threading.enumerate() ) def schedule_mdns_retry_if_needed(app, *, delay_sec: float = 30.0) -> None: """Повторный старт mDNS, если после boot/register не активен (503 Bonjour, рестарт и т.п.).""" if mdns_skip_reason_on_boot(app) is not None: return if _mdns_retry_thread_running(): return def _worker() -> None: interval = max(10.0, float(delay_sec)) attempt = 0 while True: time.sleep(interval) if mdns_skip_reason_on_boot(app) is not None: return if mdns_status().get("mdns_active"): return attempt += 1 _log.info("mDNS: повторная регистрация (попытка %s)", attempt) with app.app_context(): start_mdns(app) if mdns_status().get("mdns_active"): return threading.Thread(target=_worker, daemon=True, name="wesp-mdns-retry").start() def register_mdns_shutdown(app) -> None: """Снять mDNS-запись при остановке процесса — иначе .local остаётся «мёртвым» после рестарта.""" global _mdns_shutdown_registered if _mdns_shutdown_registered or app.config.get("TESTING"): return _mdns_shutdown_registered = True def _on_exit() -> None: stop_mdns(app, log_event=False) atexit.register(_on_exit) def reset_mdns_state_for_tests() -> None: global _zeroconf, _service_info, _active, _last_error, _registered_key, _last_logged_error with _lock: _zeroconf = None _service_info = None _active = False _last_error = None _registered_key = None _last_logged_error = None