"""Discovery: mDNS-Service-Modell + manuelle Fallback-Liste (PLAN.md §6.3; ADR-0009). - Service-Typ: _hmsmedia._tcp.local. - TXT nur kleine, nicht vertrauliche Daten: proto, node, roles, port, caps - manuelle Node-Liste für VLANs/geroutete Netze (§6.3) - IP-Wechsel ändert die node_id nicht (§6.3) """ from __future__ import annotations import hashlib import json from dataclasses import dataclass from pathlib import Path SERVICE_TYPE = "_hmsmedia._tcp.local." DISCOVERY_PROTOCOL_VERSION = 1 @dataclass(frozen=True) class ServiceInfo: """mDNS-Ankündigung eines Nodes (TXT-Inhalt, ADR-0009).""" node_id: str display_name: str port: int roles: tuple[str, ...] protocol_version: int = DISCOVERY_PROTOCOL_VERSION capability_digest: str = "" @property def instance_name(self) -> str: """Eindeutiger Instanzname: bereinigter Anzeigename.""" safe = "".join(c for c in self.display_name if c.isalnum() or c in " -_") return safe[:63] or self.node_id[:8] def txt(self) -> dict[str, str]: """TXT-Record: klein, nicht vertraulich (ADR-0009, §27.1).""" return { "proto": str(self.protocol_version), "node": self.node_id, "roles": ",".join(self.roles), "port": str(self.port), "caps": self.capability_digest[:16], } @classmethod def from_txt(cls, instance_name: str, port: int, txt: dict[str, str]) -> ServiceInfo: """Parst eine fremde Ankündigung; wirft bei unvollständigen Daten.""" required = ("proto", "node", "port") missing = [k for k in required if k not in txt] if missing: raise ValueError(f"TXT unvollstaendig, fehlt: {missing}") node_id = txt["node"] if len(node_id) < 8: raise ValueError("node-Eintrag ungueltig") roles = tuple(r for r in txt.get("roles", "").split(",") if r) return cls( node_id=node_id, display_name=instance_name, port=int(txt["port"]) or port, roles=roles, protocol_version=int(txt["proto"]), capability_digest=txt.get("caps", ""), ) def capability_digest(capabilities: dict) -> str: """Kurzer, stabiler Digest über Capabilities (ADR-0009 TXT 'caps').""" canonical = json.dumps(capabilities, sort_keys=True, separators=(",", ":")) return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16] @dataclass class ManualNodeList: """Persistente manuelle Node-Liste (Fallback ohne mDNS, §6.3). JSON-Format: Liste von {"host", "port", "node_id"}; ungeprüfte Hosts bleiben Kategorie `unknown`, bis sich die Node legitim identifiziert. """ path: Path def save(self, entries: list[dict]) -> None: clean = [ { "host": str(e.get("host", "")), "port": int(e.get("port", 0)), "node_id": str(e.get("node_id", "")), } for e in entries ] self.path.parent.mkdir(parents=True, exist_ok=True) self.path.write_text( json.dumps(clean, indent=2, ensure_ascii=False), encoding="utf-8" ) def load(self) -> list[dict]: if not self.path.is_file(): return [] try: data = json.loads(self.path.read_text(encoding="utf-8")) except json.JSONDecodeError: return [] entries = [] for item in data if isinstance(data, list) else []: if not isinstance(item, dict): continue host = str(item.get("host", "")) if not host: continue try: port = int(item.get("port", 0)) except (TypeError, ValueError): continue entries.append( {"host": host, "port": port, "node_id": str(item.get("node_id", ""))} ) return entries def add(self, host: str, port: int, node_id: str = "") -> None: entries = self.load() if any(e["host"] == host and e["port"] == port for e in entries): return entries.append({"host": host, "port": port, "node_id": node_id}) self.save(entries) def remove(self, host: str, port: int) -> None: entries = [e for e in self.load() if not (e["host"] == host and e["port"] == port)] self.save(entries)