AUFGERAUMT: Root auf 10 sichtbare Elemente reduziert
Der Nutzer hat recht: Der Ordner war voller Entwicklungs-Muell. Jetzt ist sauber getrennt: ROOT (was der Nutzer sieht und braucht): - run.py = das Programm - hms_app/ = der Anwendungscode - HMS MediaEngine.app = macOS Doppelklick-Starter - HMS-Start.vbs = Windows Doppelklick-Starter - HMS-Install.vbs = Windows Erst-Installation - HMS-Mac-Install.command = macOS Homebrew-Installation - HMS-Portable-Install.command = macOS Portable-Installation (16GB-Fix) - installer_gui.py = grafischer Installer - launcher.pyw + launcher_core.py = interne Start-Logik - LIESMICH.txt = 10-Zeilen-Kurzanleitung - .gitignore _entwicklung/ (alles andere, NICHT benoetigt): - packages/ apps/ native/ plugins/ tools/ schemas/ tests/ docs/ build/ fixture_profiles/ - PLAN.md STATUS.md ERRORS.md TEST_REPORT.md CHANGELOG.md README.md - pyproject.toml uv.lock setup_*.sh/ps1 make_mac_app.py Diese Trennung gilt ab sofort fuer alle Commits. Der Nutzer kann _entwicklung/ loeschen wenn er Platz braucht - die App laeuft ohne. Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
"""Node-Registry mit Heartbeat-Zuständen (PLAN.md §6.3, §6.5).
|
||||
|
||||
- Zustände je Node: online / degraded / stale / offline mit konfigurierbaren
|
||||
Schwellen (§6.5)
|
||||
- doppelte node_id wird als Fehler blockiert, nie still übernommen (§6.3)
|
||||
- Kategorien für die UI: discovered / paired / unknown / incompatible /
|
||||
offline werden getrennt geführt (§6.3)
|
||||
- persistente node_id bleibt identisch bei IP-Wechsel; Endpunkte werden
|
||||
als „zuletzt bekannt" aktualisiert (§10.1 Node)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class NodeHealth(StrEnum):
|
||||
ONLINE = "online"
|
||||
DEGRADED = "degraded"
|
||||
STALE = "stale"
|
||||
OFFLINE = "offline"
|
||||
|
||||
|
||||
class NodeCategory(StrEnum):
|
||||
"""UI-Kategorien gemäß §6.3: gefunden, gepaart, unbekannt, inkompatibel,
|
||||
offline werden getrennt aufgeführt."""
|
||||
|
||||
DISCOVERED = "discovered"
|
||||
PAIRED = "paired"
|
||||
UNKNOWN = "unknown"
|
||||
INCOMPATIBLE = "incompatible"
|
||||
OFFLINE = "offline"
|
||||
|
||||
|
||||
class DuplicateNodeError(Exception):
|
||||
"""Doppelte node_id – wird als Fehler blockiert (§6.3)."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeEntry:
|
||||
"""Registry-Eintrag: Identität stabil, Endpunkte „zuletzt bekannt" (§10.1)."""
|
||||
|
||||
node_id: str
|
||||
display_name: str
|
||||
roles: tuple[str, ...] = ()
|
||||
api_port: int = 0
|
||||
protocol_version: int = 1
|
||||
capability_digest: str = ""
|
||||
last_known_endpoints: list[str] = field(default_factory=list)
|
||||
last_heartbeat_ns: int = 0
|
||||
health: NodeHealth = NodeHealth.OFFLINE
|
||||
category: NodeCategory = NodeCategory.DISCOVERED
|
||||
clock_offset_ns: int = 0
|
||||
|
||||
def record_endpoint(self, endpoint: str) -> None:
|
||||
"""IP-Wechsel: node_id bleibt, Endpunkt wird aktualisiert (§6.3)."""
|
||||
if endpoint in self.last_known_endpoints:
|
||||
self.last_known_endpoints.remove(endpoint)
|
||||
self.last_known_endpoints.insert(0, endpoint)
|
||||
del self.last_known_endpoints[4:] # die letzten 5 genügen
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthThresholds:
|
||||
"""Konfigurierbare Schwellen je Zustand (§6.5).
|
||||
|
||||
Heartbeat pünktlich < degraded_after_ns; verspätet, aber vorhanden
|
||||
< stale_after_ns; danach offline. Standard-Heartbeat 500 ms (§6.5).
|
||||
"""
|
||||
|
||||
heartbeat_interval_ns: int = 500_000_000
|
||||
degraded_after_ns: int = 2_000_000_000 # 2 s ohne Heartbeat
|
||||
stale_after_ns: int = 5_000_000_000 # 5 s ohne Heartbeat
|
||||
|
||||
|
||||
class NodeRegistry:
|
||||
"""Autoritative Liste bekannter Nodes (Coordinator-seitig)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
thresholds: HealthThresholds | None = None,
|
||||
protocol_version: int = 1,
|
||||
) -> None:
|
||||
self._nodes: dict[str, NodeEntry] = {}
|
||||
self._thresholds = thresholds or HealthThresholds()
|
||||
self._protocol_version = protocol_version
|
||||
|
||||
def register(
|
||||
self,
|
||||
node_id: str,
|
||||
display_name: str,
|
||||
roles: tuple[str, ...] = (),
|
||||
api_port: int = 0,
|
||||
protocol_version: int = 1,
|
||||
capability_digest: str = "",
|
||||
endpoint: str = "",
|
||||
) -> NodeEntry:
|
||||
"""Neue Node oder Update bekannter Node; Doppel-ID mit
|
||||
widersprüchlicher Identität ist ein Fehler (§6.3)."""
|
||||
existing = self._nodes.get(node_id)
|
||||
if existing is not None and existing.display_name != display_name:
|
||||
raise DuplicateNodeError(
|
||||
f"node_id {node_id} bereits als {existing.display_name!r} registriert"
|
||||
)
|
||||
if existing is None:
|
||||
entry = NodeEntry(
|
||||
node_id=node_id,
|
||||
display_name=display_name,
|
||||
roles=tuple(roles),
|
||||
api_port=api_port,
|
||||
protocol_version=protocol_version,
|
||||
capability_digest=capability_digest,
|
||||
)
|
||||
self._nodes[node_id] = entry
|
||||
else:
|
||||
entry = existing
|
||||
entry.roles = tuple(roles)
|
||||
entry.api_port = api_port
|
||||
entry.capability_digest = capability_digest
|
||||
if endpoint:
|
||||
entry.record_endpoint(endpoint)
|
||||
# Inkompatible Protokollversion sichtbar kategorisieren (§6.3)
|
||||
if entry.protocol_version != self._protocol_version:
|
||||
entry.category = NodeCategory.INCOMPATIBLE
|
||||
return entry
|
||||
|
||||
def record_heartbeat(self, node_id: str, clock_offset_ns: int = 0) -> None:
|
||||
entry = self._nodes.get(node_id)
|
||||
if entry is None:
|
||||
raise KeyError(f"unknown node {node_id}")
|
||||
entry.last_heartbeat_ns = time.monotonic_ns()
|
||||
entry.clock_offset_ns = clock_offset_ns
|
||||
|
||||
def evaluate_health(self, node_id: str) -> NodeHealth:
|
||||
"""Berechnet den Zustand aus letztem Heartbeat + Schwellen (§6.5)."""
|
||||
entry = self._nodes[node_id]
|
||||
if entry.last_heartbeat_ns == 0:
|
||||
entry.health = NodeHealth.OFFLINE
|
||||
if entry.category not in (NodeCategory.INCOMPATIBLE, NodeCategory.PAIRED):
|
||||
entry.category = NodeCategory.DISCOVERED
|
||||
return entry.health
|
||||
elapsed = time.monotonic_ns() - entry.last_heartbeat_ns
|
||||
if elapsed < self._thresholds.degraded_after_ns:
|
||||
entry.health = NodeHealth.ONLINE
|
||||
elif elapsed < self._thresholds.stale_after_ns:
|
||||
entry.health = NodeHealth.DEGRADED
|
||||
else:
|
||||
entry.health = NodeHealth.OFFLINE
|
||||
if entry.category is NodeCategory.PAIRED:
|
||||
entry.category = NodeCategory.OFFLINE # Vertrauen bleibt, nur weg
|
||||
return entry.health
|
||||
|
||||
def mark_paired(self, node_id: str) -> None:
|
||||
self._nodes[node_id].category = NodeCategory.PAIRED
|
||||
|
||||
def mark_unknown(self, node_id: str) -> None:
|
||||
self._nodes[node_id].category = NodeCategory.UNKNOWN
|
||||
|
||||
def get(self, node_id: str) -> NodeEntry | None:
|
||||
return self._nodes.get(node_id)
|
||||
|
||||
def by_category(self, category: NodeCategory) -> list[NodeEntry]:
|
||||
return [n for n in self._nodes.values() if n.category is category]
|
||||
|
||||
def all(self) -> list[NodeEntry]:
|
||||
return list(self._nodes.values())
|
||||
Reference in New Issue
Block a user