204 lines
6.5 KiB
Python
204 lines
6.5 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from threading import Lock
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
from typing import Protocol
|
|
|
|
from app.config import SUPPORTED_TARGET_PORTS
|
|
|
|
|
|
DEFAULT_DDM_PATH = Path(r"C:\Program Files\Dell\Dell Display Manager 2.0\DDM.exe")
|
|
ALIENWARE_MODEL_TOKEN = "AW3423DWF"
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class DDMCommandResult:
|
|
success: bool
|
|
message: str
|
|
raw_output: str = ""
|
|
|
|
|
|
class DDMBackend(Protocol):
|
|
def is_available(self) -> bool: ...
|
|
|
|
def supports_monitor_targeting(self) -> bool: ...
|
|
|
|
def resolve_alienware_slot(self, force: bool = False) -> int | None: ...
|
|
|
|
def invalidate_slot(self) -> None: ...
|
|
|
|
def switch_to_port(self, slot: int, port_name: str) -> DDMCommandResult: ...
|
|
|
|
|
|
class RealDDMBackend:
|
|
def __init__(
|
|
self,
|
|
executable_path: Path | None = None,
|
|
max_slots: int = 8,
|
|
command_timeout_seconds: float = 4.0,
|
|
log_timeout_seconds: float = 3.0,
|
|
):
|
|
self.executable_path = executable_path or self._find_executable()
|
|
self.max_slots = max_slots
|
|
self.command_timeout_seconds = command_timeout_seconds
|
|
self.log_timeout_seconds = log_timeout_seconds
|
|
self._cached_slot: int | None = None
|
|
self._lock = Lock()
|
|
|
|
def is_available(self) -> bool:
|
|
return self.executable_path is not None and self.executable_path.exists()
|
|
|
|
def supports_monitor_targeting(self) -> bool:
|
|
return True
|
|
|
|
def invalidate_slot(self) -> None:
|
|
with self._lock:
|
|
self._cached_slot = None
|
|
|
|
def resolve_alienware_slot(self, force: bool = False) -> int | None:
|
|
if not self.is_available():
|
|
return None
|
|
|
|
with self._lock:
|
|
if self._cached_slot is not None and not force:
|
|
return self._cached_slot
|
|
|
|
for slot in range(1, self.max_slots + 1):
|
|
result = self._run_logged_command(f"/{slot}:ReadAssetAttributes")
|
|
line = _extract_result_line(result, f"{slot}:ReadAssetAttributes")
|
|
if not line:
|
|
continue
|
|
if "INVALID COMMAND" in line.upper():
|
|
continue
|
|
if ALIENWARE_MODEL_TOKEN in line.upper():
|
|
self._cached_slot = slot
|
|
return slot
|
|
|
|
self._cached_slot = None
|
|
return None
|
|
|
|
def switch_to_port(self, slot: int, port_name: str) -> DDMCommandResult:
|
|
if not self.is_available():
|
|
return DDMCommandResult(False, "DDM.exe was not found.")
|
|
|
|
port_spec = SUPPORTED_TARGET_PORTS.get(port_name.upper())
|
|
if not port_spec:
|
|
return DDMCommandResult(False, f"Port {port_name} is not supported.")
|
|
|
|
ddm_input = str(port_spec["ddm_input"])
|
|
monitor_selector = f"/MNT:{ALIENWARE_MODEL_TOKEN}"
|
|
|
|
targeted_output = self._run_logged_command(
|
|
monitor_selector,
|
|
"/WriteActiveInput",
|
|
ddm_input,
|
|
)
|
|
targeted_line = _extract_result_line_fragment(targeted_output, "WRITEACTIVEINPUT")
|
|
if not _output_has_failure(targeted_line, targeted_output):
|
|
return DDMCommandResult(
|
|
True,
|
|
f"Alienware {ALIENWARE_MODEL_TOKEN} was instructed to switch to {ddm_input}.",
|
|
targeted_output,
|
|
)
|
|
|
|
if slot > 0:
|
|
legacy_output = self._run_logged_command(f"/{slot}:WriteActiveInput", ddm_input)
|
|
legacy_line = _extract_result_line(legacy_output, f"{slot}:WriteActiveInput")
|
|
if not _output_has_failure(legacy_line, legacy_output):
|
|
return DDMCommandResult(
|
|
True,
|
|
f"Alienware DDM slot {slot} was instructed to switch to {ddm_input}.",
|
|
legacy_output,
|
|
)
|
|
merged_output = "\n".join(
|
|
block
|
|
for block in [
|
|
"[targeted]",
|
|
targeted_output.strip(),
|
|
"[legacy]",
|
|
legacy_output.strip(),
|
|
]
|
|
if block
|
|
)
|
|
return DDMCommandResult(False, "DDM rejected the WriteActiveInput command.", merged_output)
|
|
|
|
return DDMCommandResult(False, "DDM rejected the WriteActiveInput command.", targeted_output)
|
|
|
|
def _run_logged_command(self, *command_args: str) -> str:
|
|
assert self.executable_path is not None
|
|
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as handle:
|
|
log_path = Path(handle.name)
|
|
|
|
try:
|
|
subprocess.run(
|
|
[str(self.executable_path), "/Log", str(log_path), *command_args],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=self.command_timeout_seconds,
|
|
)
|
|
|
|
deadline = time.monotonic() + self.log_timeout_seconds
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
contents = log_path.read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
contents = ""
|
|
if contents.strip():
|
|
return contents
|
|
time.sleep(0.1)
|
|
|
|
try:
|
|
return log_path.read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
return ""
|
|
finally:
|
|
try:
|
|
log_path.unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
def _find_executable(self) -> Path | None:
|
|
resolved = shutil.which("DDM.exe")
|
|
if resolved:
|
|
return Path(resolved)
|
|
if DEFAULT_DDM_PATH.exists():
|
|
return DEFAULT_DDM_PATH
|
|
return None
|
|
|
|
|
|
def _extract_result_line(text: str, prefix: str) -> str:
|
|
for line in text.splitlines():
|
|
normalized = line.strip()
|
|
if normalized.startswith(prefix):
|
|
return normalized
|
|
return ""
|
|
|
|
|
|
def _extract_result_line_fragment(text: str, fragment: str) -> str:
|
|
needle = fragment.upper()
|
|
for line in text.splitlines():
|
|
normalized = line.strip()
|
|
if needle in normalized.upper():
|
|
return normalized
|
|
return ""
|
|
|
|
|
|
def _output_has_failure(line: str, output: str) -> bool:
|
|
normalized_line = line.upper()
|
|
normalized_output = output.upper()
|
|
checks = (
|
|
"INVALID COMMAND",
|
|
"UNSUPPORTED",
|
|
"NOT FOUND",
|
|
"FAILED",
|
|
"FAIL",
|
|
)
|
|
return any(token in normalized_line or token in normalized_output for token in checks)
|