Add auxiliary monitor configuration and enhance trigger logic for KVM switching
This commit is contained in:
+30
-6
@@ -18,13 +18,20 @@ SUPPORTED_TARGET_PORTS = {
|
||||
@dataclass(slots=True)
|
||||
class AppConfig:
|
||||
device_port: str = "DP1"
|
||||
auxiliary_monitor_id: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, object]) -> "AppConfig":
|
||||
return cls(device_port=_coerce_port_name(data.get("device_port"), "DP1"))
|
||||
return cls(
|
||||
device_port=_coerce_port_name(data.get("device_port"), "DP1"),
|
||||
auxiliary_monitor_id=_coerce_aux_monitor_id(data.get("auxiliary_monitor_id")),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
return {"device_port": self.device_port}
|
||||
def to_dict(self) -> dict[str, str | None]:
|
||||
return {
|
||||
"device_port": self.device_port,
|
||||
"auxiliary_monitor_id": self.auxiliary_monitor_id,
|
||||
}
|
||||
|
||||
def validate(self) -> list[str]:
|
||||
errors: list[str] = []
|
||||
@@ -43,7 +50,10 @@ class ConfigStore:
|
||||
|
||||
def get(self) -> AppConfig:
|
||||
with self._lock:
|
||||
return AppConfig(device_port=self._config.device_port)
|
||||
return AppConfig(
|
||||
device_port=self._config.device_port,
|
||||
auxiliary_monitor_id=self._config.auxiliary_monitor_id,
|
||||
)
|
||||
|
||||
def save(self, config: AppConfig) -> AppConfig:
|
||||
errors = config.validate()
|
||||
@@ -55,8 +65,14 @@ class ConfigStore:
|
||||
json.dumps(config.to_dict(), indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self._config = AppConfig(device_port=config.device_port)
|
||||
return AppConfig(device_port=self._config.device_port)
|
||||
self._config = AppConfig(
|
||||
device_port=config.device_port,
|
||||
auxiliary_monitor_id=config.auxiliary_monitor_id,
|
||||
)
|
||||
return AppConfig(
|
||||
device_port=self._config.device_port,
|
||||
auxiliary_monitor_id=self._config.auxiliary_monitor_id,
|
||||
)
|
||||
|
||||
def _load_from_disk(self) -> AppConfig:
|
||||
if not self.path.exists():
|
||||
@@ -79,3 +95,11 @@ def _coerce_port_name(value: object, default: str) -> str:
|
||||
if normalized in SUPPORTED_TARGET_PORTS:
|
||||
return normalized
|
||||
return default
|
||||
|
||||
|
||||
def _coerce_aux_monitor_id(value: object) -> str | None:
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip()
|
||||
if normalized:
|
||||
return normalized
|
||||
return None
|
||||
|
||||
+54
-19
@@ -10,14 +10,30 @@ from app.config import SUPPORTED_TARGET_PORTS
|
||||
ALIENWARE_MODEL_TOKEN = "AW3423DWF"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TriggerMonitorCandidate:
|
||||
id: str
|
||||
label: str
|
||||
input_code: int | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, int | str | None]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"label": self.label,
|
||||
"input_code": self.input_code,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class HardwareScan:
|
||||
samsung_present: bool = False
|
||||
trigger_input_code: int | None = None
|
||||
trigger_monitor_id: str | None = None
|
||||
alienware_detected: bool = False
|
||||
alienware_input_code: int | None = None
|
||||
trigger_description: str | None = None
|
||||
alienware_description: str | None = None
|
||||
trigger_candidates: list[TriggerMonitorCandidate] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@@ -68,36 +84,44 @@ class RealMonitorBackend:
|
||||
|
||||
trigger_description: str | None = None
|
||||
trigger_input_code: int | None = None
|
||||
trigger_monitor_id: str | None = None
|
||||
trigger_candidates = (
|
||||
samsung_named_candidates
|
||||
if samsung_named_candidates
|
||||
else (non_alienware_candidates if samsung_present else [])
|
||||
)
|
||||
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,
|
||||
scanned_candidates: list[TriggerMonitorCandidate] = []
|
||||
for index, (monitor, description) in enumerate(trigger_candidates, start=1):
|
||||
local_errors: list[str] = []
|
||||
input_code = self._read_input_code(
|
||||
monitor,
|
||||
"Samsung trigger candidate monitor",
|
||||
local_errors,
|
||||
)
|
||||
elif not trigger_candidates:
|
||||
scanned_candidates.append(
|
||||
TriggerMonitorCandidate(
|
||||
id=_build_trigger_candidate_id(description, index),
|
||||
label=description,
|
||||
input_code=input_code,
|
||||
)
|
||||
)
|
||||
|
||||
if len(scanned_candidates) == 1:
|
||||
selected = scanned_candidates[0]
|
||||
trigger_description = selected.label
|
||||
trigger_input_code = selected.input_code
|
||||
trigger_monitor_id = selected.id
|
||||
elif not scanned_candidates:
|
||||
errors.append("Samsung trigger monitor could not be identified through DDC/CI.")
|
||||
else:
|
||||
scanned_candidates: list[tuple[object, str, int | None]] = []
|
||||
for monitor, description in trigger_candidates:
|
||||
local_errors: list[str] = []
|
||||
input_code = self._read_input_code(
|
||||
monitor,
|
||||
"Samsung trigger candidate monitor",
|
||||
local_errors,
|
||||
)
|
||||
scanned_candidates.append((monitor, description, input_code))
|
||||
|
||||
selected_index = _select_trigger_candidate_index(
|
||||
[(description, input_code) for _, description, input_code in scanned_candidates]
|
||||
[(candidate.label, candidate.input_code) for candidate in scanned_candidates]
|
||||
)
|
||||
if selected_index is not None:
|
||||
_, trigger_description, trigger_input_code = scanned_candidates[selected_index]
|
||||
selected = scanned_candidates[selected_index]
|
||||
trigger_description = selected.label
|
||||
trigger_input_code = selected.input_code
|
||||
trigger_monitor_id = selected.id
|
||||
elif samsung_named_candidates:
|
||||
errors.append("Multiple Samsung DDC monitors were detected; trigger monitor is ambiguous.")
|
||||
else:
|
||||
@@ -111,10 +135,12 @@ class RealMonitorBackend:
|
||||
return HardwareScan(
|
||||
samsung_present=samsung_present,
|
||||
trigger_input_code=trigger_input_code,
|
||||
trigger_monitor_id=trigger_monitor_id,
|
||||
alienware_detected=len(alienware_candidates) == 1,
|
||||
alienware_input_code=alienware_input_code,
|
||||
trigger_description=trigger_description,
|
||||
alienware_description=alienware_description,
|
||||
trigger_candidates=scanned_candidates,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -222,3 +248,12 @@ def _select_trigger_candidate_index(candidates: list[tuple[str, int | None]]) ->
|
||||
return generic[0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _build_trigger_candidate_id(description: str, index: int) -> str:
|
||||
normalized = description.strip().lower()
|
||||
safe = "".join(ch if ch.isalnum() else "-" for ch in normalized).strip("-")
|
||||
safe = "-".join(part for part in safe.split("-") if part)
|
||||
if not safe:
|
||||
safe = "monitor"
|
||||
return f"{safe}-{index}"
|
||||
|
||||
@@ -19,6 +19,7 @@ INDEX_PATH = STATIC_DIR / "index.html"
|
||||
|
||||
class SettingsPayload(BaseModel):
|
||||
device_port: str
|
||||
auxiliary_monitor_id: str | None = None
|
||||
|
||||
|
||||
def create_app(
|
||||
@@ -59,6 +60,7 @@ def create_app(
|
||||
try:
|
||||
return active_service.save_settings(
|
||||
device_port=payload.device_port,
|
||||
auxiliary_monitor_id=payload.auxiliary_monitor_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
+71
-11
@@ -11,7 +11,7 @@ from app.config import (
|
||||
SUPPORTED_TARGET_PORTS,
|
||||
)
|
||||
from app.ddm import DDMBackend, RealDDMBackend
|
||||
from app.hardware import HardwareScan, MonitorBackend, RealMonitorBackend
|
||||
from app.hardware import HardwareScan, MonitorBackend, RealMonitorBackend, TriggerMonitorCandidate
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -24,8 +24,10 @@ class ServiceStatus:
|
||||
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
|
||||
@@ -44,8 +46,10 @@ class ServiceStatus:
|
||||
"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,
|
||||
@@ -100,8 +104,11 @@ class KvmSwitcherService:
|
||||
with self._state_lock:
|
||||
return self._status.to_dict()
|
||||
|
||||
def save_settings(self, device_port: str) -> dict[str, Any]:
|
||||
new_config = AppConfig(device_port=device_port)
|
||||
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()
|
||||
|
||||
@@ -112,7 +119,11 @@ class KvmSwitcherService:
|
||||
|
||||
errors = list(scan.errors)
|
||||
errors.extend(config.validate())
|
||||
blocking_errors = [error for error in errors if _is_blocking_error(error)]
|
||||
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(
|
||||
@@ -125,9 +136,10 @@ class KvmSwitcherService:
|
||||
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(scan.trigger_input_code)
|
||||
desired_port = self._resolve_desired_port(config, scan)
|
||||
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:
|
||||
@@ -150,6 +162,7 @@ class KvmSwitcherService:
|
||||
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,
|
||||
@@ -171,9 +184,11 @@ class KvmSwitcherService:
|
||||
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,
|
||||
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,
|
||||
@@ -201,9 +216,8 @@ class KvmSwitcherService:
|
||||
@staticmethod
|
||||
def _resolve_desired_port(
|
||||
config: AppConfig,
|
||||
scan: HardwareScan,
|
||||
trigger_input: int | None,
|
||||
) -> str | None:
|
||||
trigger_input = scan.trigger_input_code
|
||||
if trigger_input is None:
|
||||
return None
|
||||
desired_codes = KvmSwitcherService._port_input_codes(config.device_port)
|
||||
@@ -243,6 +257,7 @@ class KvmSwitcherService:
|
||||
|
||||
def _attempt_switch_sequence(
|
||||
self,
|
||||
config: AppConfig,
|
||||
ddm_slot: int | None,
|
||||
desired_port: str,
|
||||
scan: HardwareScan,
|
||||
@@ -274,8 +289,13 @@ class KvmSwitcherService:
|
||||
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_scan.trigger_input_code not in desired_codes:
|
||||
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(
|
||||
@@ -290,6 +310,39 @@ class KvmSwitcherService:
|
||||
|
||||
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] = []
|
||||
@@ -308,6 +361,13 @@ def _is_blocking_error(error: str) -> bool:
|
||||
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.",
|
||||
"Samsung trigger monitor did not expose a Samsung model in DDC/CI; using the only non-Alienware DDC monitor as trigger.",
|
||||
)
|
||||
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.",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user