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
+1
View File
@@ -0,0 +1 @@
"""Internal KVM Switch application package."""
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
from dataclasses import dataclass
import json
import os
from pathlib import Path
from threading import Lock
PROJECT_ROOT = Path(__file__).resolve().parent.parent
CONFIG_PATH = PROJECT_ROOT / "config.json"
DEVICE_ROLE_ENV_VAR = "KVM_DEVICE_ROLE"
TOWER_TRIGGER_CODE = 15
LAPTOP_TRIGGER_CODE = 19
SUPPORTED_DEVICE_ROLES = {"tower", "laptop"}
SUPPORTED_TARGET_PORTS = {
"DP1": {"ddm_input": "DP1", "input_codes": {15}},
"DP2": {"ddm_input": "DP2", "input_codes": {19}},
"HDMI": {"ddm_input": "HDMI", "input_codes": {17}},
}
@dataclass(slots=True)
class AppConfig:
device_role: str | None = None
device_port: str = "DP1"
@classmethod
def from_dict(cls, data: dict[str, object], default_role: str | None = None) -> "AppConfig":
return cls(
device_role=_coerce_role_name(data.get("device_role"), default_role),
device_port=_coerce_port_name(data.get("device_port"), "DP1"),
)
def to_dict(self) -> dict[str, str | None]:
return {
"device_role": self.device_role,
"device_port": self.device_port,
}
def validate(self) -> list[str]:
errors: list[str] = []
if self.device_role not in SUPPORTED_DEVICE_ROLES:
errors.append(
f"Device role is not configured. Set it in the UI, set {DEVICE_ROLE_ENV_VAR} to TOWER or LAPTOP, or add device_role to config.json."
)
supported = ", ".join(SUPPORTED_TARGET_PORTS)
if self.device_port not in SUPPORTED_TARGET_PORTS:
errors.append(f"Device Port must be one of: {supported}.")
return errors
class ConfigStore:
def __init__(self, path: Path = CONFIG_PATH):
self.path = path
self._lock = Lock()
self._default_role = _coerce_role_name(os.environ.get(DEVICE_ROLE_ENV_VAR), None)
self._config = self._load_from_disk()
def get(self) -> AppConfig:
with self._lock:
return AppConfig(
device_role=self._config.device_role,
device_port=self._config.device_port,
)
def save(self, config: AppConfig) -> AppConfig:
errors = config.validate()
if errors:
raise ValueError("; ".join(errors))
with self._lock:
self.path.write_text(
json.dumps(config.to_dict(), indent=2),
encoding="utf-8",
)
self._config = AppConfig(
device_role=config.device_role,
device_port=config.device_port,
)
return AppConfig(
device_role=self._config.device_role,
device_port=self._config.device_port,
)
def _load_from_disk(self) -> AppConfig:
if not self.path.exists():
return AppConfig(device_role=self._default_role)
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return AppConfig(device_role=self._default_role)
if not isinstance(data, dict):
return AppConfig(device_role=self._default_role)
return AppConfig.from_dict(data, default_role=self._default_role)
def _coerce_port_name(value: object, default: str) -> str:
if isinstance(value, str):
normalized = value.strip().upper()
if normalized in SUPPORTED_TARGET_PORTS:
return normalized
return default
def _coerce_role_name(value: object, default: str | None) -> str | None:
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in SUPPORTED_DEVICE_ROLES:
return normalized
return default
+150
View File
@@ -0,0 +1,150 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from threading import Lock
import shutil
import subprocess
import tempfile
import time
from typing import Protocol
from app.config import SUPPORTED_TARGET_PORTS
DEFAULT_DDM_PATH = Path(r"C:\Program Files\Dell\Dell Display Manager 2.0\DDM.exe")
ALIENWARE_MODEL_TOKEN = "AW3423DWF"
@dataclass(slots=True)
class DDMCommandResult:
success: bool
message: str
raw_output: str = ""
class DDMBackend(Protocol):
def is_available(self) -> bool: ...
def resolve_alienware_slot(self, force: bool = False) -> int | None: ...
def invalidate_slot(self) -> None: ...
def switch_to_port(self, slot: int, port_name: str) -> DDMCommandResult: ...
class RealDDMBackend:
def __init__(
self,
executable_path: Path | None = None,
max_slots: int = 8,
command_timeout_seconds: float = 4.0,
log_timeout_seconds: float = 3.0,
):
self.executable_path = executable_path or self._find_executable()
self.max_slots = max_slots
self.command_timeout_seconds = command_timeout_seconds
self.log_timeout_seconds = log_timeout_seconds
self._cached_slot: int | None = None
self._lock = Lock()
def is_available(self) -> bool:
return self.executable_path is not None and self.executable_path.exists()
def invalidate_slot(self) -> None:
with self._lock:
self._cached_slot = None
def resolve_alienware_slot(self, force: bool = False) -> int | None:
if not self.is_available():
return None
with self._lock:
if self._cached_slot is not None and not force:
return self._cached_slot
for slot in range(1, self.max_slots + 1):
result = self._run_logged_command(f"/{slot}:ReadAssetAttributes")
line = _extract_result_line(result, f"{slot}:ReadAssetAttributes")
if not line:
continue
if "INVALID COMMAND" in line.upper():
continue
if ALIENWARE_MODEL_TOKEN in line.upper():
self._cached_slot = slot
return slot
self._cached_slot = None
return None
def switch_to_port(self, slot: int, port_name: str) -> DDMCommandResult:
if not self.is_available():
return DDMCommandResult(False, "DDM.exe was not found.")
port_spec = SUPPORTED_TARGET_PORTS.get(port_name.upper())
if not port_spec:
return DDMCommandResult(False, f"Port {port_name} is not supported.")
ddm_input = str(port_spec["ddm_input"])
output = self._run_logged_command(f"/{slot}:WriteActiveInput", ddm_input)
line = _extract_result_line(output, f"{slot}:WriteActiveInput")
if line and "INVALID COMMAND" in line.upper():
return DDMCommandResult(False, "DDM rejected the WriteActiveInput command.", output)
return DDMCommandResult(
True,
f"Alienware DDM slot {slot} was instructed to switch to {ddm_input}.",
output,
)
def _run_logged_command(self, *command_args: str) -> str:
assert self.executable_path is not None
with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as handle:
log_path = Path(handle.name)
try:
subprocess.run(
[str(self.executable_path), "/Log", str(log_path), *command_args],
check=False,
capture_output=True,
text=True,
timeout=self.command_timeout_seconds,
)
deadline = time.monotonic() + self.log_timeout_seconds
while time.monotonic() < deadline:
try:
contents = log_path.read_text(encoding="utf-8", errors="replace")
except OSError:
contents = ""
if contents.strip():
return contents
time.sleep(0.1)
try:
return log_path.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
finally:
try:
log_path.unlink()
except OSError:
pass
def _find_executable(self) -> Path | None:
resolved = shutil.which("DDM.exe")
if resolved:
return Path(resolved)
if DEFAULT_DDM_PATH.exists():
return DEFAULT_DDM_PATH
return None
def _extract_result_line(text: str, prefix: str) -> str:
for line in text.splitlines():
normalized = line.strip()
if normalized.startswith(prefix):
return normalized
return ""
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol
from monitorcontrol import get_monitors
ALIENWARE_MODEL_TOKEN = "AW3423DWF"
@dataclass(slots=True)
class HardwareScan:
samsung_present: bool = False
trigger_input_code: int | None = None
alienware_detected: bool = False
alienware_input_code: int | None = None
trigger_description: str | None = None
alienware_description: str | None = None
errors: list[str] = field(default_factory=list)
class MonitorBackend(Protocol):
def scan(self) -> HardwareScan: ...
class RealMonitorBackend:
def scan(self) -> HardwareScan:
errors: list[str] = []
samsung_present = self._is_samsung_present(errors)
try:
monitors = list(get_monitors())
except Exception as exc: # pragma: no cover - hardware path
errors.append(f"Unable to enumerate DDC monitors: {exc}")
return HardwareScan(samsung_present=samsung_present, errors=errors)
alienware_candidates: list[tuple[object, str]] = []
trigger_candidates: list[tuple[object, str]] = []
for monitor in monitors:
description = str(getattr(getattr(monitor, "vcp", None), "description", "") or "")
normalized = description.upper()
if ALIENWARE_MODEL_TOKEN in normalized:
alienware_candidates.append((monitor, description))
else:
trigger_candidates.append((monitor, description or "Unknown DDC monitor"))
alienware_description: str | None = None
alienware_input_code: int | None = None
if len(alienware_candidates) == 1:
alienware_monitor, alienware_description = alienware_candidates[0]
alienware_input_code = self._read_input_code(
alienware_monitor,
"Alienware target monitor",
errors,
)
elif not alienware_candidates:
errors.append("Alienware AW3423DWF could not be identified through DDC/CI.")
else:
errors.append("Multiple Alienware AW3423DWF monitors were detected; refusing to switch.")
trigger_description: str | None = None
trigger_input_code: int | None = None
if len(trigger_candidates) == 1:
trigger_monitor, trigger_description = trigger_candidates[0]
trigger_input_code = self._read_input_code(
trigger_monitor,
"Samsung trigger monitor",
errors,
)
elif not trigger_candidates:
errors.append("A non-Alienware DDC monitor was not found for trigger-only logic.")
else:
errors.append("Multiple non-Alienware DDC monitors were detected; trigger monitor is ambiguous.")
if not samsung_present:
errors.append("Samsung monitor could not be confirmed from Windows monitor metadata.")
return HardwareScan(
samsung_present=samsung_present,
trigger_input_code=trigger_input_code,
alienware_detected=len(alienware_candidates) == 1,
alienware_input_code=alienware_input_code,
trigger_description=trigger_description,
alienware_description=alienware_description,
errors=errors,
)
def _is_samsung_present(self, errors: list[str]) -> bool:
try:
import win32com.client # pyright: ignore[reportMissingImports]
except ImportError as exc: # pragma: no cover - dependency issue
errors.append(f"pywin32 is unavailable for WMI monitor detection: {exc}")
return False
try:
locator = win32com.client.Dispatch("WbemScripting.SWbemLocator")
service = locator.ConnectServer(".", "root\\wmi")
query = "SELECT InstanceName, ManufacturerName, UserFriendlyName FROM WmiMonitorID"
for row in service.ExecQuery(query):
instance_name = str(getattr(row, "InstanceName", "") or "")
manufacturer = _decode_wmi_string(getattr(row, "ManufacturerName", []))
friendly_name = _decode_wmi_string(getattr(row, "UserFriendlyName", []))
haystack = " ".join([instance_name, manufacturer, friendly_name]).upper()
if "SAM" in haystack:
return True
except Exception as exc: # pragma: no cover - hardware path
errors.append(f"Unable to query Windows monitor metadata: {exc}")
return False
return False
def _read_input_code(
self,
monitor: object,
label: str,
errors: list[str],
) -> int | None:
try:
with monitor:
return int(monitor.get_input_source())
except Exception as exc: # pragma: no cover - hardware path
errors.append(f"Unable to read {label} input source: {exc}")
return None
def _decode_wmi_string(raw_values: object) -> str:
if raw_values is None:
return ""
try:
values = list(raw_values)
except TypeError:
return str(raw_values)
chars: list[str] = []
for value in values:
try:
number = int(value)
except (TypeError, ValueError):
continue
if number == 0:
continue
chars.append(chr(number))
return "".join(chars).strip()
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from app.service import KvmSwitcherService
PROJECT_ROOT = Path(__file__).resolve().parent.parent
STATIC_DIR = PROJECT_ROOT / "static"
INDEX_PATH = STATIC_DIR / "index.html"
class SettingsPayload(BaseModel):
device_role: str
device_port: str
def create_app(
service: KvmSwitcherService | None = None,
manage_lifecycle: bool = True,
) -> FastAPI:
active_service = service or KvmSwitcherService()
lifespan = None
if manage_lifecycle:
@asynccontextmanager
async def lifespan(app_instance: FastAPI):
del app_instance
active_service.start()
try:
yield
finally:
active_service.stop()
app = FastAPI(title="Internal KVM Switch", version="0.1.0", lifespan=lifespan)
app.state.kvm_service = active_service
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.get("/")
async def root() -> Any:
if INDEX_PATH.exists():
return FileResponse(INDEX_PATH)
return HTMLResponse("<h1>Internal KVM Switch</h1><p>Dashboard file not found.</p>")
@app.get("/api/status")
async def get_status() -> dict[str, Any]:
return active_service.get_status()
@app.post("/api/settings")
async def post_settings(payload: SettingsPayload) -> dict[str, Any]:
try:
return active_service.save_settings(
device_role=payload.device_role,
device_port=payload.device_port,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return app
app = create_app()
+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