Simplify trigger logic to port-only matching and improve Samsung detection

This commit is contained in:
Lago
2026-03-27 15:09:16 +01:00
parent 33e762c182
commit 37eb9e9fa0
8 changed files with 276 additions and 186 deletions
+13 -37
View File
@@ -4,34 +4,26 @@ FastAPI dashboard and polling service for Alienware-only KVM switching.
## Trigger Logic ## Trigger Logic
The Samsung monitor is trigger-only and uses fixed input codes: The app targets Alienware AW3423DWF for switching and uses Samsung as trigger-only.
- Tower trigger: `15` Monitor filtering:
- Laptop trigger: `19`
Each installation of this tool is for one device only. The UI lets you choose: - Alienware target monitor: description contains `AW3423DWF`
- Samsung trigger monitor: Samsung-only descriptions (`SAM...` / `SAMSUNG`)
- Other non-Alienware DDC monitors are ignored for trigger selection
- this device role: `tower` or `laptop` Configure only one value per machine:
- this device Alienware target port:
- `DP1` - this device Alienware target port: `DP1`, `DP2`, or `HDMI`
- `DP2`
- `HDMI`
The device role can also still be provided by: Samsung trigger input codes mapped by port:
- environment variable `KVM_DEVICE_ROLE` - `DP1` -> `15`
- or `config.json` field `device_role` - `DP2` -> `19`
- `HDMI` -> `17`
Valid values are: - If Samsung trigger input matches this device port code, the app runs `DDM.exe /MNT:AW3423DWF /WriteActiveInput <PORT>`.
- If it does not match, the app waits.
- `tower`
- `laptop`
Examples:
- Tower role with UI port `DP1` -> on Samsung trigger `15`, send `DDM.exe /MNT:AW3423DWF /WriteActiveInput DP1`
- Laptop role with UI port `DP2` -> on Samsung trigger `19`, send `DDM.exe /MNT:AW3423DWF /WriteActiveInput DP2`
Retry behavior: Retry behavior:
@@ -66,22 +58,6 @@ uv run monitorcontrol_main.py
### Easiest option: Startup folder ### Easiest option: Startup folder
You can now set the role in the UI, so this step is optional.
If you prefer a default role before opening the dashboard, set it for that Windows user:
Tower machine:
```powershell
[System.Environment]::SetEnvironmentVariable("KVM_DEVICE_ROLE", "tower", "User")
```
Laptop machine:
```powershell
[System.Environment]::SetEnvironmentVariable("KVM_DEVICE_ROLE", "laptop", "User")
```
1. Press `Win + R` 1. Press `Win + R`
2. Run: 2. Run:
+11 -46
View File
@@ -2,17 +2,12 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import json import json
import os
from pathlib import Path from pathlib import Path
from threading import Lock from threading import Lock
PROJECT_ROOT = Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent
CONFIG_PATH = PROJECT_ROOT / "config.json" 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 = { SUPPORTED_TARGET_PORTS = {
"DP1": {"ddm_input": "DP1", "input_codes": {15}}, "DP1": {"ddm_input": "DP1", "input_codes": {15}},
"DP2": {"ddm_input": "DP2", "input_codes": {19}}, "DP2": {"ddm_input": "DP2", "input_codes": {19}},
@@ -22,29 +17,17 @@ SUPPORTED_TARGET_PORTS = {
@dataclass(slots=True) @dataclass(slots=True)
class AppConfig: class AppConfig:
device_role: str | None = None
device_port: str = "DP1" device_port: str = "DP1"
@classmethod @classmethod
def from_dict(cls, data: dict[str, object], default_role: str | None = None) -> "AppConfig": def from_dict(cls, data: dict[str, object]) -> "AppConfig":
return cls( return cls(device_port=_coerce_port_name(data.get("device_port"), "DP1"))
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]: def to_dict(self) -> dict[str, str]:
return { return {"device_port": self.device_port}
"device_role": self.device_role,
"device_port": self.device_port,
}
def validate(self) -> list[str]: def validate(self) -> list[str]:
errors: 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) supported = ", ".join(SUPPORTED_TARGET_PORTS)
if self.device_port not in SUPPORTED_TARGET_PORTS: if self.device_port not in SUPPORTED_TARGET_PORTS:
errors.append(f"Device Port must be one of: {supported}.") errors.append(f"Device Port must be one of: {supported}.")
@@ -56,15 +39,11 @@ class ConfigStore:
def __init__(self, path: Path = CONFIG_PATH): def __init__(self, path: Path = CONFIG_PATH):
self.path = path self.path = path
self._lock = Lock() self._lock = Lock()
self._default_role = _coerce_role_name(os.environ.get(DEVICE_ROLE_ENV_VAR), None)
self._config = self._load_from_disk() self._config = self._load_from_disk()
def get(self) -> AppConfig: def get(self) -> AppConfig:
with self._lock: with self._lock:
return AppConfig( return AppConfig(device_port=self._config.device_port)
device_role=self._config.device_role,
device_port=self._config.device_port,
)
def save(self, config: AppConfig) -> AppConfig: def save(self, config: AppConfig) -> AppConfig:
errors = config.validate() errors = config.validate()
@@ -76,28 +55,22 @@ class ConfigStore:
json.dumps(config.to_dict(), indent=2), json.dumps(config.to_dict(), indent=2),
encoding="utf-8", encoding="utf-8",
) )
self._config = AppConfig( self._config = AppConfig(device_port=config.device_port)
device_role=config.device_role, return AppConfig(device_port=self._config.device_port)
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: def _load_from_disk(self) -> AppConfig:
if not self.path.exists(): if not self.path.exists():
return AppConfig(device_role=self._default_role) return AppConfig()
try: try:
data = json.loads(self.path.read_text(encoding="utf-8")) data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
return AppConfig(device_role=self._default_role) return AppConfig()
if not isinstance(data, dict): if not isinstance(data, dict):
return AppConfig(device_role=self._default_role) return AppConfig()
return AppConfig.from_dict(data, default_role=self._default_role) return AppConfig.from_dict(data)
def _coerce_port_name(value: object, default: str) -> str: def _coerce_port_name(value: object, default: str) -> str:
@@ -106,11 +79,3 @@ def _coerce_port_name(value: object, default: str) -> str:
if normalized in SUPPORTED_TARGET_PORTS: if normalized in SUPPORTED_TARGET_PORTS:
return normalized return normalized
return default 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
+35 -7
View File
@@ -36,15 +36,20 @@ class RealMonitorBackend:
return HardwareScan(samsung_present=samsung_present, errors=errors) return HardwareScan(samsung_present=samsung_present, errors=errors)
alienware_candidates: list[tuple[object, str]] = [] alienware_candidates: list[tuple[object, str]] = []
trigger_candidates: list[tuple[object, str]] = [] samsung_named_candidates: list[tuple[object, str]] = []
non_alienware_candidates: list[tuple[object, str]] = []
for monitor in monitors: for monitor in monitors:
description = str(getattr(getattr(monitor, "vcp", None), "description", "") or "") description = str(getattr(getattr(monitor, "vcp", None), "description", "") or str(monitor))
normalized = description.upper() normalized = description.upper().strip()
if ALIENWARE_MODEL_TOKEN in normalized: if ALIENWARE_MODEL_TOKEN in normalized:
alienware_candidates.append((monitor, description)) alienware_candidates.append((monitor, description))
else: continue
trigger_candidates.append((monitor, description or "Unknown DDC monitor"))
candidate = (monitor, description or "Unknown DDC monitor")
non_alienware_candidates.append(candidate)
if _is_samsung_description(normalized):
samsung_named_candidates.append(candidate)
alienware_description: str | None = None alienware_description: str | None = None
alienware_input_code: int | None = None alienware_input_code: int | None = None
@@ -62,6 +67,11 @@ class RealMonitorBackend:
trigger_description: str | None = None trigger_description: str | None = None
trigger_input_code: int | None = None trigger_input_code: int | None = None
trigger_candidates = (
samsung_named_candidates
if samsung_named_candidates
else (non_alienware_candidates if samsung_present else [])
)
if len(trigger_candidates) == 1: if len(trigger_candidates) == 1:
trigger_monitor, trigger_description = trigger_candidates[0] trigger_monitor, trigger_description = trigger_candidates[0]
trigger_input_code = self._read_input_code( trigger_input_code = self._read_input_code(
@@ -69,10 +79,19 @@ class RealMonitorBackend:
"Samsung trigger monitor", "Samsung trigger monitor",
errors, errors,
) )
if not samsung_named_candidates:
errors.append(
"Samsung trigger monitor did not expose a Samsung model in DDC/CI; using the only non-Alienware DDC monitor as trigger."
)
elif not trigger_candidates: elif not trigger_candidates:
errors.append("A non-Alienware DDC monitor was not found for trigger-only logic.") errors.append("Samsung trigger monitor could not be identified through DDC/CI.")
else: else:
errors.append("Multiple non-Alienware DDC monitors were detected; trigger monitor is ambiguous.") if samsung_named_candidates:
errors.append("Multiple Samsung DDC monitors were detected; trigger monitor is ambiguous.")
else:
errors.append(
"Multiple non-Alienware DDC monitors were detected and Samsung did not expose model info; trigger monitor is ambiguous."
)
if not samsung_present: if not samsung_present:
errors.append("Samsung monitor could not be confirmed from Windows monitor metadata.") errors.append("Samsung monitor could not be confirmed from Windows monitor metadata.")
@@ -144,3 +163,12 @@ def _decode_wmi_string(raw_values: object) -> str:
continue continue
chars.append(chr(number)) chars.append(chr(number))
return "".join(chars).strip() return "".join(chars).strip()
def _is_samsung_description(description: str) -> bool:
normalized = description.upper().strip()
if "SAMSUNG" in normalized:
return True
if normalized.startswith("SAM"):
return True
return " SAM " in f" {normalized} "
-2
View File
@@ -18,7 +18,6 @@ INDEX_PATH = STATIC_DIR / "index.html"
class SettingsPayload(BaseModel): class SettingsPayload(BaseModel):
device_role: str
device_port: str device_port: str
@@ -59,7 +58,6 @@ def create_app(
async def post_settings(payload: SettingsPayload) -> dict[str, Any]: async def post_settings(payload: SettingsPayload) -> dict[str, Any]:
try: try:
return active_service.save_settings( return active_service.save_settings(
device_role=payload.device_role,
device_port=payload.device_port, device_port=payload.device_port,
) )
except ValueError as exc: except ValueError as exc:
+55 -27
View File
@@ -8,9 +8,7 @@ from typing import Any
from app.config import ( from app.config import (
AppConfig, AppConfig,
ConfigStore, ConfigStore,
LAPTOP_TRIGGER_CODE,
SUPPORTED_TARGET_PORTS, SUPPORTED_TARGET_PORTS,
TOWER_TRIGGER_CODE,
) )
from app.ddm import DDMBackend, RealDDMBackend from app.ddm import DDMBackend, RealDDMBackend
from app.hardware import HardwareScan, MonitorBackend, RealMonitorBackend from app.hardware import HardwareScan, MonitorBackend, RealMonitorBackend
@@ -26,9 +24,10 @@ class ServiceStatus:
samsung_session_attempt_count: int = 0 samsung_session_attempt_count: int = 0
waiting_for_samsung_disconnect: bool = False waiting_for_samsung_disconnect: bool = False
trigger_input_code: int | None = None trigger_input_code: int | None = None
trigger_target_port: str | None = None
trigger_matches_device_port: bool = False
alienware_detected: bool = False alienware_detected: bool = False
alienware_input_code: int | None = None alienware_input_code: int | None = None
resolved_target: str | None = None
ddm_slot: int | None = None ddm_slot: int | None = None
ddm_ready: bool = False ddm_ready: bool = False
last_switch_result: str = "idle" last_switch_result: str = "idle"
@@ -45,9 +44,10 @@ class ServiceStatus:
"samsung_session_attempt_count": self.samsung_session_attempt_count, "samsung_session_attempt_count": self.samsung_session_attempt_count,
"waiting_for_samsung_disconnect": self.waiting_for_samsung_disconnect, "waiting_for_samsung_disconnect": self.waiting_for_samsung_disconnect,
"trigger_input_code": self.trigger_input_code, "trigger_input_code": self.trigger_input_code,
"trigger_target_port": self.trigger_target_port,
"trigger_matches_device_port": self.trigger_matches_device_port,
"alienware_detected": self.alienware_detected, "alienware_detected": self.alienware_detected,
"alienware_input_code": self.alienware_input_code, "alienware_input_code": self.alienware_input_code,
"resolved_target": self.resolved_target,
"ddm_slot": self.ddm_slot, "ddm_slot": self.ddm_slot,
"ddm_ready": self.ddm_ready, "ddm_ready": self.ddm_ready,
"last_switch_result": self.last_switch_result, "last_switch_result": self.last_switch_result,
@@ -100,11 +100,8 @@ class KvmSwitcherService:
with self._state_lock: with self._state_lock:
return self._status.to_dict() return self._status.to_dict()
def save_settings(self, device_role: str, device_port: str) -> dict[str, Any]: def save_settings(self, device_port: str) -> dict[str, Any]:
new_config = AppConfig( new_config = AppConfig(device_port=device_port)
device_role=device_role,
device_port=device_port,
)
self.config_store.save(new_config) self.config_store.save(new_config)
return self.poll_once() return self.poll_once()
@@ -115,6 +112,7 @@ class KvmSwitcherService:
errors = list(scan.errors) errors = list(scan.errors)
errors.extend(config.validate()) errors.extend(config.validate())
blocking_errors = [error for error in errors if _is_blocking_error(error)]
ddm_slot = self.ddm_backend.resolve_alienware_slot(force=False) ddm_slot = self.ddm_backend.resolve_alienware_slot(force=False)
supports_monitor_targeting = bool( supports_monitor_targeting = bool(
@@ -128,15 +126,16 @@ class KvmSwitcherService:
elif ddm_slot is None and not supports_monitor_targeting: elif ddm_slot is None and not supports_monitor_targeting:
errors.append("Alienware DDM slot could not be resolved.") errors.append("Alienware DDM slot could not be resolved.")
resolved_target, desired_port = self._resolve_target(config, scan) trigger_target_port = self._port_name_for_input_code(scan.trigger_input_code)
desired_port = self._resolve_desired_port(config, scan)
trigger_matches_device_port = desired_port is not None
last_switch_result = "idle" last_switch_result = "idle"
with self._state_lock: with self._state_lock:
last_switch_at = self._status.last_switch_at last_switch_at = self._status.last_switch_at
should_attempt_switch = ( should_attempt_switch = (
not errors not blocking_errors
and desired_port is not None and desired_port is not None
and scan.alienware_input_code is not None
and ddm_ready and ddm_ready
and scan.samsung_present and scan.samsung_present
and not self._samsung_session_successful and not self._samsung_session_successful
@@ -145,7 +144,7 @@ class KvmSwitcherService:
if should_attempt_switch: if should_attempt_switch:
desired_codes = self._port_input_codes(desired_port) desired_codes = self._port_input_codes(desired_port)
if scan.alienware_input_code in desired_codes: if scan.alienware_input_code is not None and scan.alienware_input_code in desired_codes:
last_switch_result = "noop" last_switch_result = "noop"
self._samsung_session_attempted = True self._samsung_session_attempted = True
self._samsung_session_successful = True self._samsung_session_successful = True
@@ -157,12 +156,12 @@ class KvmSwitcherService:
last_switch_at=last_switch_at, last_switch_at=last_switch_at,
errors=errors, errors=errors,
) )
elif desired_port is not None and not config.validate(): elif trigger_matches_device_port and self._samsung_session_successful:
last_switch_result = "blocked"
elif resolved_target == config.device_role and self._samsung_session_successful:
last_switch_result = "waiting_for_disconnect" last_switch_result = "waiting_for_disconnect"
elif resolved_target == config.device_role and self._samsung_session_attempt_count >= 3: elif trigger_matches_device_port and self._samsung_session_attempt_count >= 3:
last_switch_result = "max_attempts_waiting_for_disconnect" last_switch_result = "max_attempts_waiting_for_disconnect"
elif scan.samsung_present:
last_switch_result = "waiting_for_trigger_match"
status = ServiceStatus( status = ServiceStatus(
config=config, config=config,
@@ -173,9 +172,10 @@ class KvmSwitcherService:
samsung_session_attempt_count=self._samsung_session_attempt_count, samsung_session_attempt_count=self._samsung_session_attempt_count,
waiting_for_samsung_disconnect=self._samsung_session_successful or self._samsung_session_attempt_count >= 3, 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=scan.trigger_input_code,
trigger_target_port=trigger_target_port,
trigger_matches_device_port=trigger_matches_device_port,
alienware_detected=scan.alienware_detected, alienware_detected=scan.alienware_detected,
alienware_input_code=scan.alienware_input_code, alienware_input_code=scan.alienware_input_code,
resolved_target=resolved_target,
ddm_slot=ddm_slot, ddm_slot=ddm_slot,
ddm_ready=ddm_ready, ddm_ready=ddm_ready,
last_switch_result=last_switch_result, last_switch_result=last_switch_result,
@@ -199,19 +199,17 @@ class KvmSwitcherService:
self._stop_event.wait(self.poll_interval_seconds) self._stop_event.wait(self.poll_interval_seconds)
@staticmethod @staticmethod
def _resolve_target( def _resolve_desired_port(
config: AppConfig, config: AppConfig,
scan: HardwareScan, scan: HardwareScan,
) -> tuple[str | None, str | None]: ) -> str | None:
trigger_input = scan.trigger_input_code trigger_input = scan.trigger_input_code
if trigger_input is None: if trigger_input is None:
return None, None return None
desired_codes = KvmSwitcherService._port_input_codes(config.device_port)
if trigger_input == TOWER_TRIGGER_CODE: if trigger_input in desired_codes:
return "tower", config.device_port if config.device_role == "tower" else None return config.device_port
if trigger_input == LAPTOP_TRIGGER_CODE: return None
return "laptop", config.device_port if config.device_role == "laptop" else None
return None, None
@staticmethod @staticmethod
def _port_input_codes(port_name: str) -> set[int]: def _port_input_codes(port_name: str) -> set[int]:
@@ -219,6 +217,17 @@ class KvmSwitcherService:
raw_codes = port_spec.get("input_codes", set()) raw_codes = port_spec.get("input_codes", set())
return {int(code) for code in raw_codes} 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: def _update_samsung_session(self, samsung_present: bool) -> None:
if samsung_present: if samsung_present:
if not self._samsung_session_active: if not self._samsung_session_active:
@@ -266,6 +275,15 @@ class KvmSwitcherService:
return "waiting_for_reconnect", last_switch_at, scan, errors return "waiting_for_reconnect", last_switch_at, scan, errors
desired_codes = self._port_input_codes(desired_port) desired_codes = self._port_input_codes(desired_port)
if verify_scan.trigger_input_code 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 DDM command completed."
)
self._samsung_session_successful = True
return "switched_unverified", last_switch_at, scan, errors
if verify_scan.alienware_input_code in desired_codes: if verify_scan.alienware_input_code in desired_codes:
self._samsung_session_successful = True self._samsung_session_successful = True
return last_result, last_switch_at, scan, errors return last_result, last_switch_at, scan, errors
@@ -283,3 +301,13 @@ def _dedupe_errors(errors: list[str]) -> list[str]:
seen.add(normalized) seen.add(normalized)
unique.append(normalized) unique.append(normalized)
return unique 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 DDM 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)
+18 -12
View File
@@ -1,22 +1,28 @@
from monitorcontrol import get_monitors from monitorcontrol import get_monitors
def get_monitor_info(): def get_monitor_info():
results = [] results = []
for monitor in get_monitors(): for monitor in get_monitors():
description = str(getattr(getattr(monitor, "vcp", None), "description", "") or str(monitor)) # Set a breakpoint here to see each monitor object as it's found
monitor_data = {"name": description, "input": "unavailable"} m_data = {"name": "Unknown/Internal", "input": "N/A", "caps": ""}
try: try:
with monitor: with monitor:
monitor_data["input"] = str(monitor.get_input_source()) # This is the line that was crashing; now it's protected
except Exception as exc: m_data["caps"] = monitor.get_vcp_capabilities()
monitor_data["input"] = f"error: {exc}" m_data["input"] = str(monitor.get_input_source())
results.append(monitor_data)
if "AW34" in m_data["caps"]:
m_data["name"] = "ALIENWARE (Target)"
elif "SAM" in m_data["caps"]:
m_data["name"] = "SAMSUNG (Trigger)"
except Exception:
# If a monitor (like the laptop screen) fails, we just skip it
continue
results.append(m_data)
return results return results
if __name__ == "__main__": if __name__ == "__main__":
print("Connected monitor diagnostics:") print("Searching for DDC/CI compatible monitors...")
monitors = get_monitor_info() for m in get_monitor_info():
for m in monitors: print(f"Detected: {m['name']} | Current Input: {m['input']}")
print(f"Monitor: {m['name']} | Current Input: {m['input']}")
+6 -25
View File
@@ -250,7 +250,7 @@
<p class="eyebrow">Alienware-Only Targeting</p> <p class="eyebrow">Alienware-Only Targeting</p>
<h1>Internal KVM Switch Dashboard</h1> <h1>Internal KVM Switch Dashboard</h1>
<p> <p>
Samsung is trigger-only and uses fixed codes: Tower = 15 and Laptop = 19. This dashboard only controls which Alienware input port each machine should switch to. Samsung is trigger-only. This device switches Alienware AW3423DWF when Samsung input matches the configured local port code: DP1 = 15, DP2 = 19, HDMI = 17.
</p> </p>
</section> </section>
@@ -258,13 +258,6 @@
<article class="card"> <article class="card">
<h2>Settings</h2> <h2>Settings</h2>
<form id="settings-form"> <form id="settings-form">
<label class="field" for="device-role">
<span>This Device Role</span>
<select id="device-role" required>
<option value="tower">Tower</option>
<option value="laptop">Laptop</option>
</select>
</label>
<label class="field" for="device-port"> <label class="field" for="device-port">
<span>This Device Alienware Port</span> <span>This Device Alienware Port</span>
<select id="device-port" required> <select id="device-port" required>
@@ -273,7 +266,7 @@
<option value="HDMI">HDMI</option> <option value="HDMI">HDMI</option>
</select> </select>
</label> </label>
<p class="hint">Samsung trigger logic is fixed: <code>15 = Tower</code>, <code>19 = Laptop</code>. The selected role and port are saved locally in <code>config.json</code>.</p> <p class="hint">The selected port is saved locally in <code>config.json</code>.</p>
<div class="actions"> <div class="actions">
<button id="save-btn" type="submit">Save Settings</button> <button id="save-btn" type="submit">Save Settings</button>
</div> </div>
@@ -293,7 +286,6 @@
<script> <script>
const els = { const els = {
form: document.getElementById("settings-form"), form: document.getElementById("settings-form"),
role: document.getElementById("device-role"),
port: document.getElementById("device-port"), port: document.getElementById("device-port"),
saveBtn: document.getElementById("save-btn"), saveBtn: document.getElementById("save-btn"),
msg: document.getElementById("form-message"), msg: document.getElementById("form-message"),
@@ -313,26 +305,20 @@
function renderStatus(payload) { function renderStatus(payload) {
const config = payload.config || {}; const config = payload.config || {};
const roleVal = config.device_role;
const portVal = config.device_port; const portVal = config.device_port;
if (!formDirty && document.activeElement !== els.role) {
els.role.value = roleVal ?? "tower";
}
if (!formDirty && document.activeElement !== els.port) { if (!formDirty && document.activeElement !== els.port) {
els.port.value = portVal ?? "DP1"; els.port.value = portVal ?? "DP1";
} }
els.stats.innerHTML = [ els.stats.innerHTML = [
stat("Device Role", config.device_role),
stat("This Device Port", config.device_port), stat("This Device Port", config.device_port),
stat("Samsung Present", payload.samsung_present ? "Yes" : "No"), stat("Samsung Present", payload.samsung_present ? "Yes" : "No"),
stat("Trigger Input", payload.trigger_input_code), stat("Trigger Input", payload.trigger_input_code),
stat("Tower Trigger", "15"), stat("Trigger Target Port", payload.trigger_target_port),
stat("Laptop Trigger", "19"), stat("Trigger Matches This Device Port", payload.trigger_matches_device_port ? "Yes" : "No"),
stat("Alienware Detected", payload.alienware_detected ? "Yes" : "No"), stat("Alienware Detected", payload.alienware_detected ? "Yes" : "No"),
stat("Alienware Input", payload.alienware_input_code), stat("Alienware Input", payload.alienware_input_code),
stat("Resolved Target", payload.resolved_target),
stat("Attempted This Samsung Session", payload.samsung_session_attempted ? "Yes" : "No"), stat("Attempted This Samsung Session", payload.samsung_session_attempted ? "Yes" : "No"),
stat("Successful This Samsung Session", payload.samsung_session_successful ? "Yes" : "No"), stat("Successful This Samsung Session", payload.samsung_session_successful ? "Yes" : "No"),
stat("Attempts This Samsung Session", payload.samsung_session_attempt_count), stat("Attempts This Samsung Session", payload.samsung_session_attempt_count),
@@ -372,12 +358,11 @@
async function saveSettings(event) { async function saveSettings(event) {
event.preventDefault(); event.preventDefault();
const role = els.role.value;
const port = els.port.value; const port = els.port.value;
if (!role || !port) { if (!port) {
els.msg.className = "message error"; els.msg.className = "message error";
els.msg.textContent = "Both device role and device port must be selected."; els.msg.textContent = "A device port must be selected.";
return; return;
} }
@@ -390,7 +375,6 @@
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
device_role: role,
device_port: port, device_port: port,
}), }),
}); });
@@ -428,9 +412,6 @@
els.port.addEventListener("change", () => { els.port.addEventListener("change", () => {
formDirty = true; formDirty = true;
}); });
els.role.addEventListener("change", () => {
formDirty = true;
});
els.form.addEventListener("submit", saveSettings); els.form.addEventListener("submit", saveSettings);
loadStatus(true); loadStatus(true);
setInterval(() => loadStatus(false), 1500); setInterval(() => loadStatus(false), 1500);
+138 -30
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
import importlib import importlib
import os
import sys import sys
import tempfile import tempfile
@@ -13,7 +12,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path: if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
from app.config import AppConfig, ConfigStore, DEVICE_ROLE_ENV_VAR from app.config import AppConfig, ConfigStore
def _require_module(module_name: str): def _require_module(module_name: str):
@@ -23,24 +22,32 @@ def _require_module(module_name: str):
pytest.skip(f"Module '{module_name}' is not available yet: {exc}") pytest.skip(f"Module '{module_name}' is not available yet: {exc}")
def test_config_store_missing_file_and_roundtrip_save(monkeypatch: pytest.MonkeyPatch) -> None: def test_config_store_missing_file_and_roundtrip_save() -> None:
monkeypatch.setenv(DEVICE_ROLE_ENV_VAR, "tower")
config_path = Path(tempfile.mkdtemp()) / "config.json" config_path = Path(tempfile.mkdtemp()) / "config.json"
store = ConfigStore(config_path) store = ConfigStore(config_path)
assert store.get() == AppConfig(device_role="tower", device_port="DP1") assert store.get() == AppConfig(device_port="DP1")
assert not config_path.exists() assert not config_path.exists()
saved = store.save(AppConfig(device_role="tower", device_port="HDMI")) saved = store.save(AppConfig(device_port="HDMI"))
assert saved.device_role == "tower"
assert saved.device_port == "HDMI" assert saved.device_port == "HDMI"
assert config_path.exists() assert config_path.exists()
reloaded = ConfigStore(config_path).get() reloaded = ConfigStore(config_path).get()
assert reloaded.device_role == "tower"
assert reloaded.device_port == "HDMI" assert reloaded.device_port == "HDMI"
def test_config_store_loads_legacy_config_with_device_role() -> None:
config_path = Path(tempfile.mkdtemp()) / "config.json"
config_path.write_text(
'{\n "device_role": "laptop",\n "device_port": "DP2"\n}\n',
encoding="utf-8",
)
loaded = ConfigStore(config_path).get()
assert loaded.device_port == "DP2"
def test_api_status_and_settings_endpoints_with_fake_service() -> None: def test_api_status_and_settings_endpoints_with_fake_service() -> None:
app_main = _require_module("app.main") app_main = _require_module("app.main")
create_app = getattr(app_main, "create_app", None) create_app = getattr(app_main, "create_app", None)
@@ -50,7 +57,7 @@ def test_api_status_and_settings_endpoints_with_fake_service() -> None:
class FakeService: class FakeService:
def __init__(self) -> None: def __init__(self) -> None:
self.payload = { self.payload = {
"config": {"device_role": "tower", "device_port": "DP1"}, "config": {"device_port": "DP1"},
"samsung_present": False, "samsung_present": False,
"samsung_connected_session_active": False, "samsung_connected_session_active": False,
"samsung_session_attempted": False, "samsung_session_attempted": False,
@@ -58,9 +65,10 @@ def test_api_status_and_settings_endpoints_with_fake_service() -> None:
"samsung_session_attempt_count": 0, "samsung_session_attempt_count": 0,
"waiting_for_samsung_disconnect": False, "waiting_for_samsung_disconnect": False,
"trigger_input_code": None, "trigger_input_code": None,
"trigger_target_port": None,
"trigger_matches_device_port": False,
"alienware_detected": False, "alienware_detected": False,
"alienware_input_code": None, "alienware_input_code": None,
"resolved_target": None,
"ddm_slot": None, "ddm_slot": None,
"ddm_ready": False, "ddm_ready": False,
"last_switch_result": "idle", "last_switch_result": "idle",
@@ -77,11 +85,8 @@ def test_api_status_and_settings_endpoints_with_fake_service() -> None:
def get_status(self) -> dict[str, object]: def get_status(self) -> dict[str, object]:
return self.payload return self.payload
def save_settings(self, device_role: str, device_port: str) -> dict[str, object]: def save_settings(self, device_port: str) -> dict[str, object]:
self.payload["config"] = { self.payload["config"] = {"device_port": device_port}
"device_role": device_role,
"device_port": device_port,
}
self.payload["last_switch_result"] = "updated" self.payload["last_switch_result"] = "updated"
return self.payload return self.payload
@@ -89,19 +94,17 @@ def test_api_status_and_settings_endpoints_with_fake_service() -> None:
with TestClient(app) as client: with TestClient(app) as client:
status_response = client.get("/api/status") status_response = client.get("/api/status")
assert status_response.status_code == 200 assert status_response.status_code == 200
assert status_response.json()["config"]["device_role"] == "tower" assert status_response.json()["config"]["device_port"] == "DP1"
save_response = client.post( save_response = client.post(
"/api/settings", "/api/settings",
json={"device_role": "laptop", "device_port": "HDMI"}, json={"device_port": "HDMI", "device_role": "legacy_ignored"},
) )
assert save_response.status_code == 200 assert save_response.status_code == 200
assert save_response.json()["config"]["device_role"] == "laptop"
assert save_response.json()["config"]["device_port"] == "HDMI" assert save_response.json()["config"]["device_port"] == "HDMI"
def test_polling_switches_when_trigger_matches_device_role(monkeypatch: pytest.MonkeyPatch) -> None: def test_polling_switches_when_trigger_matches_device_port() -> None:
monkeypatch.setenv(DEVICE_ROLE_ENV_VAR, "laptop")
service_module = _require_module("app.service") service_module = _require_module("app.service")
hardware_module = _require_module("app.hardware") hardware_module = _require_module("app.hardware")
ddm_module = _require_module("app.ddm") ddm_module = _require_module("app.ddm")
@@ -121,14 +124,14 @@ def test_polling_switches_when_trigger_matches_device_role(monkeypatch: pytest.M
if self.call_count == 1: if self.call_count == 1:
return HardwareScan( return HardwareScan(
samsung_present=True, samsung_present=True,
trigger_input_code=19, trigger_input_code=17,
alienware_detected=True, alienware_detected=True,
alienware_input_code=15, alienware_input_code=15,
errors=[], errors=[],
) )
return HardwareScan( return HardwareScan(
samsung_present=True, samsung_present=True,
trigger_input_code=19, trigger_input_code=17,
alienware_detected=True, alienware_detected=True,
alienware_input_code=17, alienware_input_code=17,
errors=[], errors=[],
@@ -160,19 +163,69 @@ def test_polling_switches_when_trigger_matches_device_role(monkeypatch: pytest.M
poll_interval_seconds=0.01, poll_interval_seconds=0.01,
retry_wait_seconds=0.0, retry_wait_seconds=0.0,
) )
status = service.save_settings(device_role="laptop", device_port="HDMI") status = service.save_settings(device_port="HDMI")
assert status["resolved_target"] == "laptop" assert status["trigger_target_port"] == "HDMI"
assert status["config"]["device_role"] == "laptop" assert status["trigger_matches_device_port"] is True
assert status["config"]["device_port"] == "HDMI" assert status["config"]["device_port"] == "HDMI"
assert status["samsung_session_attempted"] is True assert status["samsung_session_attempted"] is True
assert status["samsung_session_successful"] is True assert status["samsung_session_successful"] is True
assert status["last_switch_result"] == "switched" assert status["last_switch_result"] == "switched"
def test_polling_switches_with_monitor_targeting_when_slot_is_unavailable( def test_polling_does_not_switch_when_trigger_does_not_match_device_port() -> None:
monkeypatch: pytest.MonkeyPatch, service_module = _require_module("app.service")
) -> None: hardware_module = _require_module("app.hardware")
monkeypatch.setenv(DEVICE_ROLE_ENV_VAR, "tower") ddm_module = _require_module("app.ddm")
KvmSwitcherService = getattr(service_module, "KvmSwitcherService", None)
HardwareScan = getattr(hardware_module, "HardwareScan", None)
DDMCommandResult = getattr(ddm_module, "DDMCommandResult", None)
if KvmSwitcherService is None or HardwareScan is None or DDMCommandResult is None:
pytest.skip("Service backend interfaces are not available yet.")
class FakeMonitorBackend:
def scan(self):
return HardwareScan(
samsung_present=True,
trigger_input_code=15,
alienware_detected=True,
alienware_input_code=15,
errors=[],
)
class FakeDDMBackend:
def __init__(self) -> None:
self.calls: list[tuple[int, str]] = []
def is_available(self) -> bool:
return True
def resolve_alienware_slot(self, force: bool = False) -> int | None:
return 1
def invalidate_slot(self) -> None:
return None
def switch_to_port(self, slot: int, port_name: str):
self.calls.append((slot, port_name))
return DDMCommandResult(True, "ok")
config_path = Path(tempfile.mkdtemp()) / "config.json"
backend = FakeDDMBackend()
service = KvmSwitcherService(
config_store=ConfigStore(config_path),
monitor_backend=FakeMonitorBackend(),
ddm_backend=backend,
poll_interval_seconds=0.01,
retry_wait_seconds=0.0,
)
status = service.save_settings(device_port="DP2")
assert status["trigger_matches_device_port"] is False
assert status["last_switch_result"] == "waiting_for_trigger_match"
assert backend.calls == []
def test_polling_switches_with_monitor_targeting_when_slot_is_unavailable() -> None:
service_module = _require_module("app.service") service_module = _require_module("app.service")
hardware_module = _require_module("app.hardware") hardware_module = _require_module("app.hardware")
ddm_module = _require_module("app.ddm") ddm_module = _require_module("app.ddm")
@@ -234,8 +287,63 @@ def test_polling_switches_with_monitor_targeting_when_slot_is_unavailable(
poll_interval_seconds=0.01, poll_interval_seconds=0.01,
retry_wait_seconds=0.0, retry_wait_seconds=0.0,
) )
status = service.save_settings(device_role="tower", device_port="DP1") status = service.save_settings(device_port="DP1")
assert status["ddm_ready"] is True assert status["ddm_ready"] is True
assert status["ddm_slot"] is None assert status["ddm_slot"] is None
assert status["last_switch_result"] == "switched" assert status["last_switch_result"] == "switched"
assert backend.calls == [(0, "DP1")] assert backend.calls == [(0, "DP1")]
def test_polling_switches_when_alienware_input_is_unreadable() -> None:
service_module = _require_module("app.service")
hardware_module = _require_module("app.hardware")
ddm_module = _require_module("app.ddm")
KvmSwitcherService = getattr(service_module, "KvmSwitcherService", None)
HardwareScan = getattr(hardware_module, "HardwareScan", None)
DDMCommandResult = getattr(ddm_module, "DDMCommandResult", None)
if KvmSwitcherService is None or HardwareScan is None or DDMCommandResult is None:
pytest.skip("Service backend interfaces are not available yet.")
class FakeMonitorBackend:
def scan(self):
return HardwareScan(
samsung_present=True,
trigger_input_code=15,
alienware_detected=True,
alienware_input_code=None,
errors=[
"Unable to read Alienware target monitor input source: failed to get VCP feature."
],
)
class FakeDDMBackend:
def __init__(self) -> None:
self.calls: list[tuple[int, str]] = []
def is_available(self) -> bool:
return True
def resolve_alienware_slot(self, force: bool = False) -> int | None:
return 1
def invalidate_slot(self) -> None:
return None
def switch_to_port(self, slot: int, port_name: str):
self.calls.append((slot, port_name))
return DDMCommandResult(True, "ok")
config_path = Path(tempfile.mkdtemp()) / "config.json"
backend = FakeDDMBackend()
service = KvmSwitcherService(
config_store=ConfigStore(config_path),
monitor_backend=FakeMonitorBackend(),
ddm_backend=backend,
poll_interval_seconds=0.01,
retry_wait_seconds=0.0,
)
status = service.save_settings(device_port="DP1")
assert status["last_switch_result"] == "switched_unverified"
assert status["samsung_session_successful"] is True
assert backend.calls == [(1, "DP1")]