374 lines
15 KiB
Python
374 lines
15 KiB
Python
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,
|
|
SUPPORTED_TARGET_PORTS,
|
|
)
|
|
from app.ddm import DDMBackend, RealDDMBackend
|
|
from app.hardware import HardwareScan, MonitorBackend, RealMonitorBackend, TriggerMonitorCandidate
|
|
|
|
|
|
@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
|
|
active_trigger_monitor_id: str | None = None
|
|
trigger_target_port: str | None = None
|
|
trigger_matches_device_port: bool = False
|
|
trigger_monitor_candidates: list[dict[str, int | str | None]] = field(default_factory=list)
|
|
alienware_detected: bool = False
|
|
alienware_input_code: int | 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,
|
|
"active_trigger_monitor_id": self.active_trigger_monitor_id,
|
|
"trigger_target_port": self.trigger_target_port,
|
|
"trigger_matches_device_port": self.trigger_matches_device_port,
|
|
"trigger_monitor_candidates": list(self.trigger_monitor_candidates),
|
|
"alienware_detected": self.alienware_detected,
|
|
"alienware_input_code": self.alienware_input_code,
|
|
"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_port: str, auxiliary_monitor_id: str | None = None) -> dict[str, Any]:
|
|
new_config = AppConfig(
|
|
device_port=device_port,
|
|
auxiliary_monitor_id=auxiliary_monitor_id,
|
|
)
|
|
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())
|
|
trigger_input_code, active_trigger_monitor_id, trigger_candidates = self._resolve_trigger_state(
|
|
config=config,
|
|
scan=scan,
|
|
errors=errors,
|
|
)
|
|
|
|
ddm_slot = self.ddm_backend.resolve_alienware_slot(force=False)
|
|
supports_monitor_targeting = bool(
|
|
getattr(self.ddm_backend, "supports_monitor_targeting", lambda: False)()
|
|
)
|
|
ddm_ready = self.ddm_backend.is_available() and (
|
|
ddm_slot is not None or supports_monitor_targeting
|
|
)
|
|
if not self.ddm_backend.is_available():
|
|
errors.append("Alienware monitorcontrol backend is not ready.")
|
|
elif ddm_slot is None and not supports_monitor_targeting:
|
|
errors.append("Alienware monitorcontrol target could not be resolved.")
|
|
blocking_errors = [error for error in errors if _is_blocking_error(error)]
|
|
|
|
trigger_target_port = self._port_name_for_input_code(trigger_input_code)
|
|
desired_port = self._resolve_desired_port(config, trigger_input_code)
|
|
trigger_matches_device_port = desired_port is not None
|
|
last_switch_result = "idle"
|
|
with self._state_lock:
|
|
last_switch_at = self._status.last_switch_at
|
|
|
|
should_attempt_switch = (
|
|
not blocking_errors
|
|
and desired_port is not None
|
|
and ddm_ready
|
|
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 is not None and 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(
|
|
config=config,
|
|
ddm_slot=ddm_slot,
|
|
desired_port=desired_port,
|
|
scan=scan,
|
|
last_switch_at=last_switch_at,
|
|
errors=errors,
|
|
)
|
|
elif trigger_matches_device_port and self._samsung_session_successful:
|
|
last_switch_result = "waiting_for_disconnect"
|
|
elif trigger_matches_device_port and self._samsung_session_attempt_count >= 3:
|
|
last_switch_result = "max_attempts_waiting_for_disconnect"
|
|
elif scan.samsung_present:
|
|
last_switch_result = "waiting_for_trigger_match"
|
|
|
|
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=trigger_input_code,
|
|
active_trigger_monitor_id=active_trigger_monitor_id,
|
|
trigger_target_port=trigger_target_port,
|
|
trigger_matches_device_port=trigger_matches_device_port,
|
|
trigger_monitor_candidates=[candidate.to_dict() for candidate in trigger_candidates],
|
|
alienware_detected=scan.alienware_detected,
|
|
alienware_input_code=scan.alienware_input_code,
|
|
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_desired_port(
|
|
config: AppConfig,
|
|
trigger_input: int | None,
|
|
) -> str | None:
|
|
if trigger_input is None:
|
|
return None
|
|
desired_codes = KvmSwitcherService._port_input_codes(config.device_port)
|
|
if trigger_input in desired_codes:
|
|
return config.device_port
|
|
return 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}
|
|
|
|
@staticmethod
|
|
def _port_name_for_input_code(input_code: int | None) -> str | None:
|
|
if input_code is None:
|
|
return None
|
|
|
|
for port_name in SUPPORTED_TARGET_PORTS:
|
|
port_codes = KvmSwitcherService._port_input_codes(port_name)
|
|
if input_code in port_codes:
|
|
return port_name
|
|
return None
|
|
|
|
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,
|
|
config: AppConfig,
|
|
ddm_slot: int | None,
|
|
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
|
|
|
|
switch_slot = ddm_slot if ddm_slot is not None else 0
|
|
result = self.ddm_backend.switch_to_port(switch_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
|
|
|
|
verify_trigger_input, _, _ = self._resolve_trigger_state(
|
|
config=config,
|
|
scan=verify_scan,
|
|
errors=errors,
|
|
)
|
|
desired_codes = self._port_input_codes(desired_port)
|
|
if verify_trigger_input not in desired_codes:
|
|
return "waiting_for_trigger_match", last_switch_at, scan, errors
|
|
if verify_scan.alienware_input_code is None:
|
|
errors.append(
|
|
"Alienware input could not be read after switch; assuming success because switch command completed."
|
|
)
|
|
self._samsung_session_successful = True
|
|
return "switched_unverified", last_switch_at, scan, errors
|
|
|
|
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
|
|
|
|
@staticmethod
|
|
def _resolve_trigger_state(
|
|
config: AppConfig,
|
|
scan: HardwareScan,
|
|
errors: list[str],
|
|
) -> tuple[int | None, str | None, list[TriggerMonitorCandidate]]:
|
|
trigger_input_code = scan.trigger_input_code
|
|
active_trigger_monitor_id = scan.trigger_monitor_id
|
|
candidates = list(scan.trigger_candidates)
|
|
|
|
configured_id = config.auxiliary_monitor_id
|
|
if configured_id is None:
|
|
return trigger_input_code, active_trigger_monitor_id, candidates
|
|
|
|
configured_candidate = next(
|
|
(candidate for candidate in candidates if candidate.id == configured_id),
|
|
None,
|
|
)
|
|
if configured_candidate is None:
|
|
errors.append(
|
|
f"Configured auxiliary monitor '{configured_id}' was not found among detected trigger monitors."
|
|
)
|
|
return trigger_input_code, active_trigger_monitor_id, candidates
|
|
|
|
errors[:] = [error for error in errors if not _is_trigger_ambiguity_error(error)]
|
|
trigger_input_code = configured_candidate.input_code
|
|
active_trigger_monitor_id = configured_candidate.id
|
|
if trigger_input_code is None:
|
|
errors.append(
|
|
f"Unable to read configured auxiliary monitor input source: {configured_candidate.label}."
|
|
)
|
|
return trigger_input_code, active_trigger_monitor_id, candidates
|
|
|
|
|
|
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
|
|
|
|
|
|
def _is_blocking_error(error: str) -> bool:
|
|
normalized = error.strip()
|
|
non_blocking_prefixes = (
|
|
"Unable to read Alienware target monitor input source:",
|
|
"Alienware input could not be read after switch; assuming success because switch command completed.",
|
|
)
|
|
return not any(normalized.startswith(prefix) for prefix in non_blocking_prefixes)
|
|
|
|
|
|
def _is_trigger_ambiguity_error(error: str) -> bool:
|
|
normalized = error.strip()
|
|
return normalized in {
|
|
"Multiple Samsung DDC monitors were detected; trigger monitor is ambiguous.",
|
|
"Multiple non-Alienware DDC monitors were detected and Samsung did not expose model info; trigger monitor is ambiguous.",
|
|
}
|