Simplify trigger logic to port-only matching and improve Samsung detection
This commit is contained in:
@@ -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
@@ -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
@@ -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} "
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")]
|
||||||
|
|||||||
Reference in New Issue
Block a user