Add internal KVM switch dashboard and service
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Internal KVM Switch application package."""
|
||||
+116
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user