Add internal KVM switch dashboard and service

This commit is contained in:
Lago
2026-03-27 14:18:36 +01:00
commit 8591e22a7b
16 changed files with 1908 additions and 0 deletions
+279
View File
@@ -0,0 +1,279 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
from threading import Event, Lock, Thread
from typing import Any
from app.config import (
AppConfig,
ConfigStore,
LAPTOP_TRIGGER_CODE,
SUPPORTED_TARGET_PORTS,
TOWER_TRIGGER_CODE,
)
from app.ddm import DDMBackend, RealDDMBackend
from app.hardware import HardwareScan, MonitorBackend, RealMonitorBackend
@dataclass(slots=True)
class ServiceStatus:
config: AppConfig = field(default_factory=AppConfig)
samsung_present: bool = False
samsung_connected_session_active: bool = False
samsung_session_attempted: bool = False
samsung_session_successful: bool = False
samsung_session_attempt_count: int = 0
waiting_for_samsung_disconnect: bool = False
trigger_input_code: int | None = None
alienware_detected: bool = False
alienware_input_code: int | None = None
resolved_target: str | None = None
ddm_slot: int | None = None
ddm_ready: bool = False
last_switch_result: str = "idle"
last_switch_at: str | None = None
errors: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"config": self.config.to_dict(),
"samsung_present": self.samsung_present,
"samsung_connected_session_active": self.samsung_connected_session_active,
"samsung_session_attempted": self.samsung_session_attempted,
"samsung_session_successful": self.samsung_session_successful,
"samsung_session_attempt_count": self.samsung_session_attempt_count,
"waiting_for_samsung_disconnect": self.waiting_for_samsung_disconnect,
"trigger_input_code": self.trigger_input_code,
"alienware_detected": self.alienware_detected,
"alienware_input_code": self.alienware_input_code,
"resolved_target": self.resolved_target,
"ddm_slot": self.ddm_slot,
"ddm_ready": self.ddm_ready,
"last_switch_result": self.last_switch_result,
"last_switch_at": self.last_switch_at,
"errors": list(self.errors),
}
class KvmSwitcherService:
def __init__(
self,
config_store: ConfigStore | None = None,
monitor_backend: MonitorBackend | None = None,
ddm_backend: DDMBackend | None = None,
poll_interval_seconds: float = 0.25,
retry_wait_seconds: float = 5.0,
):
self.config_store = config_store or ConfigStore()
self.monitor_backend = monitor_backend or RealMonitorBackend()
self.ddm_backend = ddm_backend or RealDDMBackend()
self.poll_interval_seconds = poll_interval_seconds
self.retry_wait_seconds = retry_wait_seconds
self._status = ServiceStatus(config=self.config_store.get())
self._samsung_session_active = False
self._samsung_session_attempted = False
self._samsung_session_successful = False
self._samsung_session_attempt_count = 0
self._state_lock = Lock()
self._stop_event = Event()
self._thread: Thread | None = None
def start(self) -> None:
with self._state_lock:
if self._thread and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = Thread(target=self._run_loop, name="kvm-switch-poller", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop_event.set()
with self._state_lock:
thread = self._thread
self._thread = None
if thread and thread.is_alive():
thread.join(timeout=2.0)
def get_status(self) -> dict[str, Any]:
with self._state_lock:
return self._status.to_dict()
def save_settings(self, device_role: str, device_port: str) -> dict[str, Any]:
new_config = AppConfig(
device_role=device_role,
device_port=device_port,
)
self.config_store.save(new_config)
return self.poll_once()
def poll_once(self) -> dict[str, Any]:
config = self.config_store.get()
scan = self.monitor_backend.scan()
self._update_samsung_session(scan.samsung_present)
errors = list(scan.errors)
errors.extend(config.validate())
ddm_slot = self.ddm_backend.resolve_alienware_slot(force=False)
ddm_ready = self.ddm_backend.is_available() and ddm_slot is not None
if not self.ddm_backend.is_available():
errors.append("DDM.exe was not found.")
elif ddm_slot is None:
errors.append("Alienware DDM slot could not be resolved.")
resolved_target, desired_port = self._resolve_target(config, scan)
last_switch_result = "idle"
with self._state_lock:
last_switch_at = self._status.last_switch_at
should_attempt_switch = (
not errors
and desired_port is not None
and scan.alienware_input_code is not None
and ddm_slot is not None
and scan.samsung_present
and not self._samsung_session_successful
and self._samsung_session_attempt_count < 3
)
if should_attempt_switch:
desired_codes = self._port_input_codes(desired_port)
if scan.alienware_input_code in desired_codes:
last_switch_result = "noop"
self._samsung_session_attempted = True
self._samsung_session_successful = True
else:
last_switch_result, last_switch_at, scan, errors = self._attempt_switch_sequence(
ddm_slot=ddm_slot,
desired_port=desired_port,
scan=scan,
last_switch_at=last_switch_at,
errors=errors,
)
elif desired_port is not None and not config.validate():
last_switch_result = "blocked"
elif resolved_target == config.device_role and self._samsung_session_successful:
last_switch_result = "waiting_for_disconnect"
elif resolved_target == config.device_role and self._samsung_session_attempt_count >= 3:
last_switch_result = "max_attempts_waiting_for_disconnect"
status = ServiceStatus(
config=config,
samsung_present=scan.samsung_present,
samsung_connected_session_active=self._samsung_session_active,
samsung_session_attempted=self._samsung_session_attempted,
samsung_session_successful=self._samsung_session_successful,
samsung_session_attempt_count=self._samsung_session_attempt_count,
waiting_for_samsung_disconnect=self._samsung_session_successful or self._samsung_session_attempt_count >= 3,
trigger_input_code=scan.trigger_input_code,
alienware_detected=scan.alienware_detected,
alienware_input_code=scan.alienware_input_code,
resolved_target=resolved_target,
ddm_slot=ddm_slot,
ddm_ready=ddm_ready,
last_switch_result=last_switch_result,
last_switch_at=last_switch_at,
errors=_dedupe_errors(errors),
)
with self._state_lock:
self._status = status
return self._status.to_dict()
def _run_loop(self) -> None:
while not self._stop_event.is_set():
try:
self.poll_once()
except Exception as exc:
with self._state_lock:
errors = list(self._status.errors)
errors.append(f"Unhandled polling error: {exc}")
self._status.errors = _dedupe_errors(errors)
self._status.last_switch_result = "error"
self._stop_event.wait(self.poll_interval_seconds)
@staticmethod
def _resolve_target(
config: AppConfig,
scan: HardwareScan,
) -> tuple[str | None, str | None]:
trigger_input = scan.trigger_input_code
if trigger_input is None:
return None, None
if trigger_input == TOWER_TRIGGER_CODE:
return "tower", config.device_port if config.device_role == "tower" else None
if trigger_input == LAPTOP_TRIGGER_CODE:
return "laptop", config.device_port if config.device_role == "laptop" else None
return None, None
@staticmethod
def _port_input_codes(port_name: str) -> set[int]:
port_spec = SUPPORTED_TARGET_PORTS.get(port_name.upper(), {})
raw_codes = port_spec.get("input_codes", set())
return {int(code) for code in raw_codes}
def _update_samsung_session(self, samsung_present: bool) -> None:
if samsung_present:
if not self._samsung_session_active:
self._samsung_session_active = True
self._samsung_session_attempted = False
self._samsung_session_successful = False
self._samsung_session_attempt_count = 0
else:
self._samsung_session_active = False
self._samsung_session_attempted = False
self._samsung_session_successful = False
self._samsung_session_attempt_count = 0
def _attempt_switch_sequence(
self,
ddm_slot: int,
desired_port: str,
scan: HardwareScan,
last_switch_at: str | None,
errors: list[str],
) -> tuple[str, str | None, HardwareScan, list[str]]:
last_result = "blocked"
while self._samsung_session_attempt_count < 3:
self._samsung_session_attempted = True
self._samsung_session_attempt_count += 1
result = self.ddm_backend.switch_to_port(ddm_slot, desired_port)
if not result.success:
errors.append(result.message)
self.ddm_backend.invalidate_slot()
return "error", last_switch_at, scan, errors
last_switch_at = datetime.now(UTC).isoformat()
last_result = "switched"
self._stop_event.wait(self.retry_wait_seconds)
verify_scan = self.monitor_backend.scan()
scan = verify_scan
errors.extend(verify_scan.errors)
self._update_samsung_session(verify_scan.samsung_present)
if not verify_scan.samsung_present:
return "waiting_for_reconnect", last_switch_at, scan, errors
desired_codes = self._port_input_codes(desired_port)
if verify_scan.alienware_input_code in desired_codes:
self._samsung_session_successful = True
return last_result, last_switch_at, scan, errors
return "max_attempts_waiting_for_disconnect", last_switch_at, scan, errors
def _dedupe_errors(errors: list[str]) -> list[str]:
unique: list[str] = []
seen: set[str] = set()
for error in errors:
normalized = error.strip()
if not normalized or normalized in seen:
continue
seen.add(normalized)
unique.append(normalized)
return unique