Add internal KVM switch dashboard and service
This commit is contained in:
+116
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
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 = {
|
||||
"DP1": {"ddm_input": "DP1", "input_codes": {15}},
|
||||
"DP2": {"ddm_input": "DP2", "input_codes": {19}},
|
||||
"HDMI": {"ddm_input": "HDMI", "input_codes": {17}},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppConfig:
|
||||
device_role: str | None = None
|
||||
device_port: str = "DP1"
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, object], default_role: str | None = None) -> "AppConfig":
|
||||
return cls(
|
||||
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]:
|
||||
return {
|
||||
"device_role": self.device_role,
|
||||
"device_port": self.device_port,
|
||||
}
|
||||
|
||||
def validate(self) -> 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)
|
||||
if self.device_port not in SUPPORTED_TARGET_PORTS:
|
||||
errors.append(f"Device Port must be one of: {supported}.")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
class ConfigStore:
|
||||
def __init__(self, path: Path = CONFIG_PATH):
|
||||
self.path = path
|
||||
self._lock = Lock()
|
||||
self._default_role = _coerce_role_name(os.environ.get(DEVICE_ROLE_ENV_VAR), None)
|
||||
self._config = self._load_from_disk()
|
||||
|
||||
def get(self) -> AppConfig:
|
||||
with self._lock:
|
||||
return AppConfig(
|
||||
device_role=self._config.device_role,
|
||||
device_port=self._config.device_port,
|
||||
)
|
||||
|
||||
def save(self, config: AppConfig) -> AppConfig:
|
||||
errors = config.validate()
|
||||
if errors:
|
||||
raise ValueError("; ".join(errors))
|
||||
|
||||
with self._lock:
|
||||
self.path.write_text(
|
||||
json.dumps(config.to_dict(), indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self._config = AppConfig(
|
||||
device_role=config.device_role,
|
||||
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:
|
||||
if not self.path.exists():
|
||||
return AppConfig(device_role=self._default_role)
|
||||
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return AppConfig(device_role=self._default_role)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return AppConfig(device_role=self._default_role)
|
||||
|
||||
return AppConfig.from_dict(data, default_role=self._default_role)
|
||||
|
||||
|
||||
def _coerce_port_name(value: object, default: str) -> str:
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().upper()
|
||||
if normalized in SUPPORTED_TARGET_PORTS:
|
||||
return normalized
|
||||
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
|
||||
Reference in New Issue
Block a user