Add internal KVM switch dashboard and service

This commit is contained in:
Lago
2026-03-27 14:18:36 +01:00
commit 8591e22a7b
16 changed files with 1908 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.venv/
.pytest_cache/
__pycache__/
*.py[cod]
*.egg-info/
config.json
+1
View File
@@ -0,0 +1 @@
3.14
+116
View File
@@ -0,0 +1,116 @@
# Internal KVM Switch
FastAPI dashboard and polling service for Alienware-only KVM switching.
## Trigger Logic
The Samsung monitor is trigger-only and uses fixed input codes:
- Tower trigger: `15`
- Laptop trigger: `19`
Each installation of this tool is for one device only. The UI lets you choose:
- this device role: `tower` or `laptop`
- this device Alienware target port:
- `DP1`
- `DP2`
- `HDMI`
The device role can also still be provided by:
- environment variable `KVM_DEVICE_ROLE`
- or `config.json` field `device_role`
Valid values are:
- `tower`
- `laptop`
Examples:
- Tower role with UI port `DP1` -> on Samsung trigger `15`, send `DDM.exe /1:WriteActiveInput DP1`
- Laptop role with UI port `DP2` -> on Samsung trigger `19`, send `DDM.exe /1:WriteActiveInput DP2`
Retry behavior:
- Only attempts switching while the Samsung screen is connected
- After each switch attempt, waits `5` seconds and rechecks
- Tries at most `3` times per Samsung-connected session
- After success, it will not try again until the Samsung screen disconnects and reconnects
## Run
```powershell
uv run kvm-switch
```
The dashboard is served at `http://localhost:4000`.
## Test
```powershell
uv run pytest -q
```
## Diagnostic Script
Use this to inspect connected DDC/CI monitors and current input values:
```powershell
uv run monitorcontrol_main.py
```
## Autostart
### 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`
2. Run:
```text
shell:startup
```
3. Create a shortcut in that folder pointing to:
```text
C:\Users\LagoWorkStation\OneDrive\Documentos\BE-terna\Internal - KVM Switch\start_kvm_switch.cmd
```
This will start the app when you log in. It opens a console window.
### Cleaner option: Task Scheduler
Use Task Scheduler if you want it to start automatically at logon with better control.
Program/script:
```text
cmd.exe
```
Arguments:
```text
/c "C:\Users\LagoWorkStation\OneDrive\Documentos\BE-terna\Internal - KVM Switch\start_kvm_switch.cmd"
```
Set the trigger to `At log on`.
+1
View File
@@ -0,0 +1 @@
"""Internal KVM Switch application package."""
+116
View File
@@ -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
+150
View File
@@ -0,0 +1,150 @@
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 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 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"])
output = self._run_logged_command(f"/{slot}:WriteActiveInput", ddm_input)
line = _extract_result_line(output, f"{slot}:WriteActiveInput")
if line and "INVALID COMMAND" in line.upper():
return DDMCommandResult(False, "DDM rejected the WriteActiveInput command.", output)
return DDMCommandResult(
True,
f"Alienware DDM slot {slot} was instructed to switch to {ddm_input}.",
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 ""
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol
from monitorcontrol import get_monitors
ALIENWARE_MODEL_TOKEN = "AW3423DWF"
@dataclass(slots=True)
class HardwareScan:
samsung_present: bool = False
trigger_input_code: int | None = None
alienware_detected: bool = False
alienware_input_code: int | None = None
trigger_description: str | None = None
alienware_description: str | None = None
errors: list[str] = field(default_factory=list)
class MonitorBackend(Protocol):
def scan(self) -> HardwareScan: ...
class RealMonitorBackend:
def scan(self) -> HardwareScan:
errors: list[str] = []
samsung_present = self._is_samsung_present(errors)
try:
monitors = list(get_monitors())
except Exception as exc: # pragma: no cover - hardware path
errors.append(f"Unable to enumerate DDC monitors: {exc}")
return HardwareScan(samsung_present=samsung_present, errors=errors)
alienware_candidates: list[tuple[object, str]] = []
trigger_candidates: list[tuple[object, str]] = []
for monitor in monitors:
description = str(getattr(getattr(monitor, "vcp", None), "description", "") or "")
normalized = description.upper()
if ALIENWARE_MODEL_TOKEN in normalized:
alienware_candidates.append((monitor, description))
else:
trigger_candidates.append((monitor, description or "Unknown DDC monitor"))
alienware_description: str | None = None
alienware_input_code: int | None = None
if len(alienware_candidates) == 1:
alienware_monitor, alienware_description = alienware_candidates[0]
alienware_input_code = self._read_input_code(
alienware_monitor,
"Alienware target monitor",
errors,
)
elif not alienware_candidates:
errors.append("Alienware AW3423DWF could not be identified through DDC/CI.")
else:
errors.append("Multiple Alienware AW3423DWF monitors were detected; refusing to switch.")
trigger_description: str | None = None
trigger_input_code: int | None = None
if len(trigger_candidates) == 1:
trigger_monitor, trigger_description = trigger_candidates[0]
trigger_input_code = self._read_input_code(
trigger_monitor,
"Samsung trigger monitor",
errors,
)
elif not trigger_candidates:
errors.append("A non-Alienware DDC monitor was not found for trigger-only logic.")
else:
errors.append("Multiple non-Alienware DDC monitors were detected; trigger monitor is ambiguous.")
if not samsung_present:
errors.append("Samsung monitor could not be confirmed from Windows monitor metadata.")
return HardwareScan(
samsung_present=samsung_present,
trigger_input_code=trigger_input_code,
alienware_detected=len(alienware_candidates) == 1,
alienware_input_code=alienware_input_code,
trigger_description=trigger_description,
alienware_description=alienware_description,
errors=errors,
)
def _is_samsung_present(self, errors: list[str]) -> bool:
try:
import win32com.client # pyright: ignore[reportMissingImports]
except ImportError as exc: # pragma: no cover - dependency issue
errors.append(f"pywin32 is unavailable for WMI monitor detection: {exc}")
return False
try:
locator = win32com.client.Dispatch("WbemScripting.SWbemLocator")
service = locator.ConnectServer(".", "root\\wmi")
query = "SELECT InstanceName, ManufacturerName, UserFriendlyName FROM WmiMonitorID"
for row in service.ExecQuery(query):
instance_name = str(getattr(row, "InstanceName", "") or "")
manufacturer = _decode_wmi_string(getattr(row, "ManufacturerName", []))
friendly_name = _decode_wmi_string(getattr(row, "UserFriendlyName", []))
haystack = " ".join([instance_name, manufacturer, friendly_name]).upper()
if "SAM" in haystack:
return True
except Exception as exc: # pragma: no cover - hardware path
errors.append(f"Unable to query Windows monitor metadata: {exc}")
return False
return False
def _read_input_code(
self,
monitor: object,
label: str,
errors: list[str],
) -> int | None:
try:
with monitor:
return int(monitor.get_input_source())
except Exception as exc: # pragma: no cover - hardware path
errors.append(f"Unable to read {label} input source: {exc}")
return None
def _decode_wmi_string(raw_values: object) -> str:
if raw_values is None:
return ""
try:
values = list(raw_values)
except TypeError:
return str(raw_values)
chars: list[str] = []
for value in values:
try:
number = int(value)
except (TypeError, ValueError):
continue
if number == 0:
continue
chars.append(chr(number))
return "".join(chars).strip()
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from app.service import KvmSwitcherService
PROJECT_ROOT = Path(__file__).resolve().parent.parent
STATIC_DIR = PROJECT_ROOT / "static"
INDEX_PATH = STATIC_DIR / "index.html"
class SettingsPayload(BaseModel):
device_role: str
device_port: str
def create_app(
service: KvmSwitcherService | None = None,
manage_lifecycle: bool = True,
) -> FastAPI:
active_service = service or KvmSwitcherService()
lifespan = None
if manage_lifecycle:
@asynccontextmanager
async def lifespan(app_instance: FastAPI):
del app_instance
active_service.start()
try:
yield
finally:
active_service.stop()
app = FastAPI(title="Internal KVM Switch", version="0.1.0", lifespan=lifespan)
app.state.kvm_service = active_service
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.get("/")
async def root() -> Any:
if INDEX_PATH.exists():
return FileResponse(INDEX_PATH)
return HTMLResponse("<h1>Internal KVM Switch</h1><p>Dashboard file not found.</p>")
@app.get("/api/status")
async def get_status() -> dict[str, Any]:
return active_service.get_status()
@app.post("/api/settings")
async def post_settings(payload: SettingsPayload) -> dict[str, Any]:
try:
return active_service.save_settings(
device_role=payload.device_role,
device_port=payload.device_port,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return app
app = create_app()
+279
View File
@@ -0,0 +1,279 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
from threading import Event, Lock, Thread
from typing import Any
from app.config import (
AppConfig,
ConfigStore,
LAPTOP_TRIGGER_CODE,
SUPPORTED_TARGET_PORTS,
TOWER_TRIGGER_CODE,
)
from app.ddm import DDMBackend, RealDDMBackend
from app.hardware import HardwareScan, MonitorBackend, RealMonitorBackend
@dataclass(slots=True)
class ServiceStatus:
config: AppConfig = field(default_factory=AppConfig)
samsung_present: bool = False
samsung_connected_session_active: bool = False
samsung_session_attempted: bool = False
samsung_session_successful: bool = False
samsung_session_attempt_count: int = 0
waiting_for_samsung_disconnect: bool = False
trigger_input_code: int | None = None
alienware_detected: bool = False
alienware_input_code: int | None = None
resolved_target: str | None = None
ddm_slot: int | None = None
ddm_ready: bool = False
last_switch_result: str = "idle"
last_switch_at: str | None = None
errors: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"config": self.config.to_dict(),
"samsung_present": self.samsung_present,
"samsung_connected_session_active": self.samsung_connected_session_active,
"samsung_session_attempted": self.samsung_session_attempted,
"samsung_session_successful": self.samsung_session_successful,
"samsung_session_attempt_count": self.samsung_session_attempt_count,
"waiting_for_samsung_disconnect": self.waiting_for_samsung_disconnect,
"trigger_input_code": self.trigger_input_code,
"alienware_detected": self.alienware_detected,
"alienware_input_code": self.alienware_input_code,
"resolved_target": self.resolved_target,
"ddm_slot": self.ddm_slot,
"ddm_ready": self.ddm_ready,
"last_switch_result": self.last_switch_result,
"last_switch_at": self.last_switch_at,
"errors": list(self.errors),
}
class KvmSwitcherService:
def __init__(
self,
config_store: ConfigStore | None = None,
monitor_backend: MonitorBackend | None = None,
ddm_backend: DDMBackend | None = None,
poll_interval_seconds: float = 0.25,
retry_wait_seconds: float = 5.0,
):
self.config_store = config_store or ConfigStore()
self.monitor_backend = monitor_backend or RealMonitorBackend()
self.ddm_backend = ddm_backend or RealDDMBackend()
self.poll_interval_seconds = poll_interval_seconds
self.retry_wait_seconds = retry_wait_seconds
self._status = ServiceStatus(config=self.config_store.get())
self._samsung_session_active = False
self._samsung_session_attempted = False
self._samsung_session_successful = False
self._samsung_session_attempt_count = 0
self._state_lock = Lock()
self._stop_event = Event()
self._thread: Thread | None = None
def start(self) -> None:
with self._state_lock:
if self._thread and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = Thread(target=self._run_loop, name="kvm-switch-poller", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop_event.set()
with self._state_lock:
thread = self._thread
self._thread = None
if thread and thread.is_alive():
thread.join(timeout=2.0)
def get_status(self) -> dict[str, Any]:
with self._state_lock:
return self._status.to_dict()
def save_settings(self, device_role: str, device_port: str) -> dict[str, Any]:
new_config = AppConfig(
device_role=device_role,
device_port=device_port,
)
self.config_store.save(new_config)
return self.poll_once()
def poll_once(self) -> dict[str, Any]:
config = self.config_store.get()
scan = self.monitor_backend.scan()
self._update_samsung_session(scan.samsung_present)
errors = list(scan.errors)
errors.extend(config.validate())
ddm_slot = self.ddm_backend.resolve_alienware_slot(force=False)
ddm_ready = self.ddm_backend.is_available() and ddm_slot is not None
if not self.ddm_backend.is_available():
errors.append("DDM.exe was not found.")
elif ddm_slot is None:
errors.append("Alienware DDM slot could not be resolved.")
resolved_target, desired_port = self._resolve_target(config, scan)
last_switch_result = "idle"
with self._state_lock:
last_switch_at = self._status.last_switch_at
should_attempt_switch = (
not errors
and desired_port is not None
and scan.alienware_input_code is not None
and ddm_slot is not None
and scan.samsung_present
and not self._samsung_session_successful
and self._samsung_session_attempt_count < 3
)
if should_attempt_switch:
desired_codes = self._port_input_codes(desired_port)
if scan.alienware_input_code in desired_codes:
last_switch_result = "noop"
self._samsung_session_attempted = True
self._samsung_session_successful = True
else:
last_switch_result, last_switch_at, scan, errors = self._attempt_switch_sequence(
ddm_slot=ddm_slot,
desired_port=desired_port,
scan=scan,
last_switch_at=last_switch_at,
errors=errors,
)
elif desired_port is not None and not config.validate():
last_switch_result = "blocked"
elif resolved_target == config.device_role and self._samsung_session_successful:
last_switch_result = "waiting_for_disconnect"
elif resolved_target == config.device_role and self._samsung_session_attempt_count >= 3:
last_switch_result = "max_attempts_waiting_for_disconnect"
status = ServiceStatus(
config=config,
samsung_present=scan.samsung_present,
samsung_connected_session_active=self._samsung_session_active,
samsung_session_attempted=self._samsung_session_attempted,
samsung_session_successful=self._samsung_session_successful,
samsung_session_attempt_count=self._samsung_session_attempt_count,
waiting_for_samsung_disconnect=self._samsung_session_successful or self._samsung_session_attempt_count >= 3,
trigger_input_code=scan.trigger_input_code,
alienware_detected=scan.alienware_detected,
alienware_input_code=scan.alienware_input_code,
resolved_target=resolved_target,
ddm_slot=ddm_slot,
ddm_ready=ddm_ready,
last_switch_result=last_switch_result,
last_switch_at=last_switch_at,
errors=_dedupe_errors(errors),
)
with self._state_lock:
self._status = status
return self._status.to_dict()
def _run_loop(self) -> None:
while not self._stop_event.is_set():
try:
self.poll_once()
except Exception as exc:
with self._state_lock:
errors = list(self._status.errors)
errors.append(f"Unhandled polling error: {exc}")
self._status.errors = _dedupe_errors(errors)
self._status.last_switch_result = "error"
self._stop_event.wait(self.poll_interval_seconds)
@staticmethod
def _resolve_target(
config: AppConfig,
scan: HardwareScan,
) -> tuple[str | None, str | None]:
trigger_input = scan.trigger_input_code
if trigger_input is None:
return None, None
if trigger_input == TOWER_TRIGGER_CODE:
return "tower", config.device_port if config.device_role == "tower" else None
if trigger_input == LAPTOP_TRIGGER_CODE:
return "laptop", config.device_port if config.device_role == "laptop" else None
return None, None
@staticmethod
def _port_input_codes(port_name: str) -> set[int]:
port_spec = SUPPORTED_TARGET_PORTS.get(port_name.upper(), {})
raw_codes = port_spec.get("input_codes", set())
return {int(code) for code in raw_codes}
def _update_samsung_session(self, samsung_present: bool) -> None:
if samsung_present:
if not self._samsung_session_active:
self._samsung_session_active = True
self._samsung_session_attempted = False
self._samsung_session_successful = False
self._samsung_session_attempt_count = 0
else:
self._samsung_session_active = False
self._samsung_session_attempted = False
self._samsung_session_successful = False
self._samsung_session_attempt_count = 0
def _attempt_switch_sequence(
self,
ddm_slot: int,
desired_port: str,
scan: HardwareScan,
last_switch_at: str | None,
errors: list[str],
) -> tuple[str, str | None, HardwareScan, list[str]]:
last_result = "blocked"
while self._samsung_session_attempt_count < 3:
self._samsung_session_attempted = True
self._samsung_session_attempt_count += 1
result = self.ddm_backend.switch_to_port(ddm_slot, desired_port)
if not result.success:
errors.append(result.message)
self.ddm_backend.invalidate_slot()
return "error", last_switch_at, scan, errors
last_switch_at = datetime.now(UTC).isoformat()
last_result = "switched"
self._stop_event.wait(self.retry_wait_seconds)
verify_scan = self.monitor_backend.scan()
scan = verify_scan
errors.extend(verify_scan.errors)
self._update_samsung_session(verify_scan.samsung_present)
if not verify_scan.samsung_present:
return "waiting_for_reconnect", last_switch_at, scan, errors
desired_codes = self._port_input_codes(desired_port)
if verify_scan.alienware_input_code in desired_codes:
self._samsung_session_successful = True
return last_result, last_switch_at, scan, errors
return "max_attempts_waiting_for_disconnect", last_switch_at, scan, errors
def _dedupe_errors(errors: list[str]) -> list[str]:
unique: list[str] = []
seen: set[str] = set()
for error in errors:
normalized = error.strip()
if not normalized or normalized in seen:
continue
seen.add(normalized)
unique.append(normalized)
return unique
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
import uvicorn
def main() -> None:
uvicorn.run(
"app.main:app",
host="127.0.0.1",
port=4000,
reload=False,
)
if __name__ == "__main__":
main()
+22
View File
@@ -0,0 +1,22 @@
from monitorcontrol import get_monitors
def get_monitor_info():
results = []
for monitor in get_monitors():
description = str(getattr(getattr(monitor, "vcp", None), "description", "") or str(monitor))
monitor_data = {"name": description, "input": "unavailable"}
try:
with monitor:
monitor_data["input"] = str(monitor.get_input_source())
except Exception as exc:
monitor_data["input"] = f"error: {exc}"
results.append(monitor_data)
return results
if __name__ == "__main__":
print("Connected monitor diagnostics:")
monitors = get_monitor_info()
for m in monitors:
print(f"Monitor: {m['name']} | Current Input: {m['input']}")
+34
View File
@@ -0,0 +1,34 @@
[build-system]
requires = ["setuptools>=80"]
build-backend = "setuptools.build_meta"
[project]
name = "internal-kvm-switch"
version = "0.1.0"
description = "FastAPI dashboard and polling service for Alienware-only KVM input switching"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"fastapi>=0.135.2",
"monitorcontrol>=4.2.0",
"pywin32>=311",
"uvicorn>=0.42.0",
]
[project.scripts]
kvm-switch = "main:main"
[dependency-groups]
dev = [
"httpx>=0.28.1",
"pytest>=9.0.2",
]
[tool.uv]
package = true
[tool.setuptools]
py-modules = ["main"]
[tool.setuptools.packages.find]
include = ["app"]
+3
View File
@@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
uv run kvm-switch
+439
View File
@@ -0,0 +1,439 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Internal KVM Switch</title>
<style>
:root {
--bg-a: #f8f2e8;
--bg-b: #eef6f3;
--panel: rgba(255, 255, 255, 0.82);
--ink: #102027;
--ink-soft: #4f6169;
--line: rgba(16, 32, 39, 0.16);
--ok: #0f766e;
--warn: #b45309;
--err: #b91c1c;
--chip: #e9f6f3;
--shadow: 0 28px 56px rgba(12, 30, 44, 0.14);
--radius: 18px;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
background:
radial-gradient(1100px 520px at -10% -10%, rgba(15, 118, 110, 0.18), transparent 60%),
radial-gradient(900px 460px at 110% 110%, rgba(180, 83, 9, 0.16), transparent 60%),
linear-gradient(140deg, var(--bg-a) 0%, var(--bg-b) 100%);
}
.shell {
max-width: 1080px;
margin: 0 auto;
padding: 28px 20px 38px;
}
.hero,
.card {
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 4px);
background: var(--panel);
box-shadow: var(--shadow);
backdrop-filter: blur(8px);
}
.hero {
padding: 24px;
margin-bottom: 18px;
}
.eyebrow {
margin: 0 0 10px;
font-size: 0.78rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--ok);
font-weight: 700;
}
h1 {
margin: 0 0 12px;
font-size: clamp(1.9rem, 3.1vw, 3rem);
line-height: 1.05;
}
.hero p {
margin: 0;
color: var(--ink-soft);
line-height: 1.55;
max-width: 70ch;
}
.layout {
display: grid;
gap: 16px;
grid-template-columns: 1.05fr 1.25fr;
}
@media (max-width: 920px) {
.layout {
grid-template-columns: 1fr;
}
}
.card {
padding: 18px;
border-radius: var(--radius);
}
.card h2 {
margin: 0 0 14px;
font-size: 1.05rem;
}
.field {
display: block;
margin-bottom: 14px;
}
.field span {
display: block;
font-size: 0.92rem;
margin-bottom: 7px;
font-weight: 650;
}
input[type="number"],
select {
width: 100%;
border: 1px solid rgba(16, 32, 39, 0.2);
border-radius: 12px;
padding: 12px 13px;
font-size: 1rem;
color: var(--ink);
background: #fff;
outline: none;
transition: border-color 140ms ease, box-shadow 140ms ease;
}
input[type="number"]:focus,
select:focus {
border-color: var(--ok);
box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.15);
}
.hint {
margin: 2px 0 14px;
color: var(--ink-soft);
font-size: 0.9rem;
}
code {
background: rgba(16, 32, 39, 0.06);
border-radius: 6px;
padding: 2px 6px;
font-family: Consolas, "Courier New", monospace;
font-size: 0.86em;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
}
button {
border: 0;
border-radius: 999px;
padding: 10px 16px;
font-weight: 700;
font-size: 0.96rem;
color: #fff;
background: linear-gradient(135deg, #0f766e, #0a5b55);
box-shadow: 0 14px 28px rgba(15, 118, 110, 0.26);
cursor: pointer;
transition: transform 120ms ease, filter 120ms ease;
}
button:hover { transform: translateY(-1px); }
button:disabled { opacity: 0.68; cursor: wait; transform: none; }
.message {
min-height: 20px;
margin-top: 12px;
font-size: 0.9rem;
color: var(--ink-soft);
}
.message.error { color: var(--err); }
.message.ok { color: var(--ok); }
.stats {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 560px) {
.stats { grid-template-columns: 1fr; }
}
.stat {
border: 1px solid rgba(16, 32, 39, 0.12);
border-radius: 12px;
padding: 10px 12px;
background: linear-gradient(180deg, #ffffff, #f7faf9);
}
.stat .k {
display: block;
color: var(--ink-soft);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 5px;
font-weight: 700;
}
.stat .v {
display: block;
font-size: 1rem;
font-weight: 700;
color: var(--ink);
overflow-wrap: anywhere;
}
.tags {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag {
border-radius: 999px;
padding: 6px 10px;
font-size: 0.8rem;
font-weight: 700;
background: var(--chip);
color: #065f59;
border: 1px solid rgba(6, 95, 89, 0.18);
}
.tag.warn {
background: #fff6e9;
border-color: rgba(180, 83, 9, 0.28);
color: #92400e;
}
.errors {
margin-top: 12px;
padding-left: 18px;
color: var(--err);
font-size: 0.92rem;
}
.errors li + li {
margin-top: 5px;
}
</style>
</head>
<body>
<main class="shell">
<section class="hero">
<p class="eyebrow">Alienware-Only Targeting</p>
<h1>Internal KVM Switch Dashboard</h1>
<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.
</p>
</section>
<section class="layout">
<article class="card">
<h2>Settings</h2>
<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">
<span>This Device Alienware Port</span>
<select id="device-port" required>
<option value="DP1">DP1</option>
<option value="DP2">DP2</option>
<option value="HDMI">HDMI</option>
</select>
</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>
<div class="actions">
<button id="save-btn" type="submit">Save Settings</button>
</div>
<div id="form-message" class="message" aria-live="polite"></div>
</form>
</article>
<article class="card">
<h2>Live Status</h2>
<div id="stats" class="stats"></div>
<div id="tags" class="tags"></div>
<ul id="errors" class="errors"></ul>
</article>
</section>
</main>
<script>
const els = {
form: document.getElementById("settings-form"),
role: document.getElementById("device-role"),
port: document.getElementById("device-port"),
saveBtn: document.getElementById("save-btn"),
msg: document.getElementById("form-message"),
stats: document.getElementById("stats"),
tags: document.getElementById("tags"),
errors: document.getElementById("errors"),
};
let formDirty = false;
function safe(value) {
return value === null || value === undefined || value === "" ? "n/a" : String(value);
}
function stat(label, value) {
return `<div class="stat"><span class="k">${label}</span><span class="v">${safe(value)}</span></div>`;
}
function renderStatus(payload) {
const config = payload.config || {};
const roleVal = config.device_role;
const portVal = config.device_port;
if (!formDirty && document.activeElement !== els.role) {
els.role.value = roleVal ?? "tower";
}
if (!formDirty && document.activeElement !== els.port) {
els.port.value = portVal ?? "DP1";
}
els.stats.innerHTML = [
stat("Device Role", config.device_role),
stat("This Device Port", config.device_port),
stat("Samsung Present", payload.samsung_present ? "Yes" : "No"),
stat("Trigger Input", payload.trigger_input_code),
stat("Tower Trigger", "15"),
stat("Laptop Trigger", "19"),
stat("Alienware Detected", payload.alienware_detected ? "Yes" : "No"),
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("Successful This Samsung Session", payload.samsung_session_successful ? "Yes" : "No"),
stat("Attempts This Samsung Session", payload.samsung_session_attempt_count),
stat("Waiting For Samsung Disconnect", payload.waiting_for_samsung_disconnect ? "Yes" : "No"),
stat("DDM Slot", payload.ddm_slot),
stat("DDM Ready", payload.ddm_ready ? "Yes" : "No"),
stat("Last Result", payload.last_switch_result),
stat("Last Switch At", payload.last_switch_at),
].join("");
const tagHtml = [];
tagHtml.push(`<span class="tag${payload.ddm_ready ? "" : " warn"}">DDM ${payload.ddm_ready ? "Ready" : "Not Ready"}</span>`);
tagHtml.push(`<span class="tag${payload.alienware_detected ? "" : " warn"}">Alienware ${payload.alienware_detected ? "Detected" : "Missing"}</span>`);
tagHtml.push(`<span class="tag${payload.samsung_present ? "" : " warn"}">Samsung ${payload.samsung_present ? "Present" : "Missing"}</span>`);
tagHtml.push(`<span class="tag${payload.samsung_session_successful ? "" : " warn"}">Session ${payload.samsung_session_successful ? "Successful" : "Pending"}</span>`);
els.tags.innerHTML = tagHtml.join("");
const errors = Array.isArray(payload.errors) ? payload.errors : [];
els.errors.innerHTML = errors.map((err) => `<li>${err}</li>`).join("");
}
async function loadStatus(showErrorInForm = false) {
try {
const response = await fetch("/api/status", { cache: "no-store" });
if (!response.ok) {
throw new Error("Unable to load live status.");
}
renderStatus(await response.json());
} catch (error) {
if (showErrorInForm) {
els.msg.className = "message error";
els.msg.textContent = error.message;
}
}
}
async function saveSettings(event) {
event.preventDefault();
const role = els.role.value;
const port = els.port.value;
if (!role || !port) {
els.msg.className = "message error";
els.msg.textContent = "Both device role and device port must be selected.";
return;
}
els.saveBtn.disabled = true;
els.msg.className = "message";
els.msg.textContent = "Saving settings...";
try {
const response = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device_role: role,
device_port: port,
}),
});
if (!response.ok) {
let detail = "Settings save failed.";
try {
const payload = await response.json();
if (Array.isArray(payload.detail)) {
detail = payload.detail
.map((item) => item.msg || JSON.stringify(item))
.join(" ");
} else {
detail = payload.detail || detail;
}
} catch (_) {
detail = "Settings save failed.";
}
throw new Error(detail);
}
const payload = await response.json();
formDirty = false;
renderStatus(payload);
els.msg.className = "message ok";
els.msg.textContent = "Settings saved. Poller now uses this device port.";
} catch (error) {
els.msg.className = "message error";
els.msg.textContent = error.message;
} finally {
els.saveBtn.disabled = false;
}
}
els.port.addEventListener("change", () => {
formDirty = true;
});
els.role.addEventListener("change", () => {
formDirty = true;
});
els.form.addEventListener("submit", saveSettings);
loadStatus(true);
setInterval(() => loadStatus(false), 1500);
</script>
</body>
</html>
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
from pathlib import Path
import importlib
import os
import sys
import tempfile
import pytest
from fastapi.testclient import TestClient
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from app.config import AppConfig, ConfigStore, DEVICE_ROLE_ENV_VAR
def _require_module(module_name: str):
try:
return importlib.import_module(module_name)
except ModuleNotFoundError as 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:
monkeypatch.setenv(DEVICE_ROLE_ENV_VAR, "tower")
config_path = Path(tempfile.mkdtemp()) / "config.json"
store = ConfigStore(config_path)
assert store.get() == AppConfig(device_role="tower", device_port="DP1")
assert not config_path.exists()
saved = store.save(AppConfig(device_role="tower", device_port="HDMI"))
assert saved.device_role == "tower"
assert saved.device_port == "HDMI"
assert config_path.exists()
reloaded = ConfigStore(config_path).get()
assert reloaded.device_role == "tower"
assert reloaded.device_port == "HDMI"
def test_api_status_and_settings_endpoints_with_fake_service() -> None:
app_main = _require_module("app.main")
create_app = getattr(app_main, "create_app", None)
if create_app is None:
pytest.skip("app.main.create_app is not available yet.")
class FakeService:
def __init__(self) -> None:
self.payload = {
"config": {"device_role": "tower", "device_port": "DP1"},
"samsung_present": False,
"samsung_connected_session_active": False,
"samsung_session_attempted": False,
"samsung_session_successful": False,
"samsung_session_attempt_count": 0,
"waiting_for_samsung_disconnect": False,
"trigger_input_code": None,
"alienware_detected": False,
"alienware_input_code": None,
"resolved_target": None,
"ddm_slot": None,
"ddm_ready": False,
"last_switch_result": "idle",
"last_switch_at": None,
"errors": [],
}
def start(self) -> None:
return None
def stop(self) -> None:
return None
def get_status(self) -> dict[str, object]:
return self.payload
def save_settings(self, device_role: str, device_port: str) -> dict[str, object]:
self.payload["config"] = {
"device_role": device_role,
"device_port": device_port,
}
self.payload["last_switch_result"] = "updated"
return self.payload
app = create_app(service=FakeService(), manage_lifecycle=False)
with TestClient(app) as client:
status_response = client.get("/api/status")
assert status_response.status_code == 200
assert status_response.json()["config"]["device_role"] == "tower"
save_response = client.post(
"/api/settings",
json={"device_role": "laptop", "device_port": "HDMI"},
)
assert save_response.status_code == 200
assert save_response.json()["config"]["device_role"] == "laptop"
assert save_response.json()["config"]["device_port"] == "HDMI"
def test_polling_switches_when_trigger_matches_device_role(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(DEVICE_ROLE_ENV_VAR, "laptop")
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 __init__(self) -> None:
self.call_count = 0
def scan(self):
self.call_count += 1
if self.call_count == 1:
return HardwareScan(
samsung_present=True,
trigger_input_code=19,
alienware_detected=True,
alienware_input_code=15,
errors=[],
)
return HardwareScan(
samsung_present=True,
trigger_input_code=19,
alienware_detected=True,
alienware_input_code=17,
errors=[],
)
class FakeDDMBackend:
def __init__(self) -> None:
self.calls: list[tuple[int, str]] = []
self.slot = 1
def is_available(self) -> bool:
return True
def resolve_alienware_slot(self, force: bool = False) -> int | None:
return self.slot
def invalidate_slot(self) -> None:
self.slot = 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"
service = KvmSwitcherService(
config_store=ConfigStore(config_path),
monitor_backend=FakeMonitorBackend(),
ddm_backend=FakeDDMBackend(),
poll_interval_seconds=0.01,
retry_wait_seconds=0.0,
)
status = service.save_settings(device_role="laptop", device_port="HDMI")
assert status["resolved_target"] == "laptop"
assert status["config"]["device_role"] == "laptop"
assert status["config"]["device_port"] == "HDMI"
assert status["samsung_session_attempted"] is True
assert status["samsung_session_successful"] is True
assert status["last_switch_result"] == "switched"
Generated
+339
View File
@@ -0,0 +1,339 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fastapi"
version = "0.135.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "internal-kvm-switch"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "monitorcontrol" },
{ name = "pywin32" },
{ name = "uvicorn" },
]
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.135.2" },
{ name = "monitorcontrol", specifier = ">=4.2.0" },
{ name = "pywin32", specifier = ">=311" },
{ name = "uvicorn", specifier = ">=0.42.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pytest", specifier = ">=9.0.2" },
]
[[package]]
name = "monitorcontrol"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyudev", marker = "sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/28/c832e6479d62ac9a1f96fd76cf0148c06ee00a3e4ca26d106c2c7cd0ebd8/monitorcontrol-4.2.0.tar.gz", hash = "sha256:2686024adfecf01c3ee73c93d369bd0890fbb20e12522cb981afa27b13026b33", size = 18911, upload-time = "2026-01-03T18:32:26.34Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/cb/fdf3bdbbfe947bf6746a3549d968d77979c670e113b3210d96c185b31051/monitorcontrol-4.2.0-py3-none-any.whl", hash = "sha256:0e8fcfd21fede1b99f26f41d359b2cdecb43f62b3f803c8cb6ad3a896a77ff84", size = 17136, upload-time = "2026-01-03T18:32:25.414Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pyudev"
version = "0.24.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/1d/8bdbf651de1002e8b58fbe817bee22b1e8bfcdd24341d42c3238ce9a75f4/pyudev-0.24.4.tar.gz", hash = "sha256:e788bb983700b1a84efc2e88862b0a51af2a995d5b86bc9997546505cf7b36bc", size = 56135, upload-time = "2025-10-08T17:26:58.661Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/51/3dc0cd6498b24dea3cdeaed648568e3ca7454d41334d840b114156d7479f/pyudev-0.24.4-py3-none-any.whl", hash = "sha256:b3b6b01c68e6fc628428cc45ff3fe6c277afbb5d96507f14473ddb4a6b959e00", size = 62784, upload-time = "2025-10-08T17:26:57.664Z" },
]
[[package]]
name = "pywin32"
version = "311"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
{ url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
]
[[package]]
name = "starlette"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.42.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" },
]