@@ -0,0 +1,316 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user