Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+316
View File
@@ -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