317 lines
10 KiB
Python
317 lines
10 KiB
Python
"""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
|