feat: full implementation - switcher, device watcher, hotkeys, tray, settings UI
This commit is contained in:
124
core/device_watcher.py
Normal file
124
core/device_watcher.py
Normal file
@@ -0,0 +1,124 @@
|
||||
import logging
|
||||
import threading
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeviceWatcher:
|
||||
def __init__(self, config_manager, switcher):
|
||||
self._config_manager = config_manager
|
||||
self._switcher = switcher
|
||||
self._stop_event = threading.Event()
|
||||
self._debounce_timers: dict[str, threading.Timer] = {}
|
||||
self._debounce_lock = threading.Lock()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._watch_loop, daemon=True, name="DeviceWatcher"
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info("DeviceWatcher started")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
|
||||
def _watch_loop(self) -> None:
|
||||
retries = 0
|
||||
max_retries = 3
|
||||
while not self._stop_event.is_set() and retries <= max_retries:
|
||||
try:
|
||||
self._run_watcher()
|
||||
retries = 0
|
||||
except Exception as exc:
|
||||
retries += 1
|
||||
logger.error(
|
||||
"WMI watcher error (attempt %d/%d): %s", retries, max_retries, exc
|
||||
)
|
||||
self._stop_event.wait(5)
|
||||
logger.warning("DeviceWatcher stopped after %d retries", retries)
|
||||
|
||||
def _run_watcher(self) -> None:
|
||||
import pythoncom
|
||||
import wmi
|
||||
|
||||
pythoncom.CoInitialize()
|
||||
try:
|
||||
c = wmi.WMI()
|
||||
watcher_create = c.watch_for(
|
||||
notification_type="Creation",
|
||||
wmi_class="Win32_PnPEntity",
|
||||
delay_secs=1,
|
||||
)
|
||||
watcher_delete = c.watch_for(
|
||||
notification_type="Deletion",
|
||||
wmi_class="Win32_PnPEntity",
|
||||
delay_secs=1,
|
||||
)
|
||||
t_create = threading.Thread(
|
||||
target=self._watcher_thread,
|
||||
args=(watcher_create, "connect"),
|
||||
daemon=True,
|
||||
)
|
||||
t_delete = threading.Thread(
|
||||
target=self._watcher_thread,
|
||||
args=(watcher_delete, "disconnect"),
|
||||
daemon=True,
|
||||
)
|
||||
t_create.start()
|
||||
t_delete.start()
|
||||
t_create.join()
|
||||
t_delete.join()
|
||||
finally:
|
||||
pythoncom.CoUninitialize()
|
||||
|
||||
def _watcher_thread(self, watcher, event_type: str) -> None:
|
||||
import wmi
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
event = watcher(timeout_ms=1000)
|
||||
if event:
|
||||
self._handle_event(event.DeviceID or "", event_type)
|
||||
except wmi.x_wmi_timed_out:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.error("Watcher thread (%s) error: %s", event_type, exc)
|
||||
break
|
||||
|
||||
def _handle_event(self, device_id: str, event_type: str) -> None:
|
||||
config = self._config_manager.get()
|
||||
for trigger in config.get("device_triggers", []):
|
||||
if not trigger.get("enabled", True):
|
||||
continue
|
||||
if trigger["bt_device_id"] in device_id:
|
||||
profile_id = (
|
||||
trigger["on_connect"]
|
||||
if event_type == "connect"
|
||||
else trigger["on_disconnect"]
|
||||
)
|
||||
logger.info(
|
||||
"Device event '%s' matched trigger '%s' -> profile '%s'",
|
||||
event_type,
|
||||
trigger["name"],
|
||||
profile_id,
|
||||
)
|
||||
self._schedule_switch(device_id + event_type, profile_id)
|
||||
return
|
||||
|
||||
def _schedule_switch(self, key: str, profile_id: str) -> None:
|
||||
debounce_s = (
|
||||
self._config_manager.get()
|
||||
.get("general", {})
|
||||
.get("debounce_ms", 500) / 1000.0
|
||||
)
|
||||
with self._debounce_lock:
|
||||
existing = self._debounce_timers.get(key)
|
||||
if existing:
|
||||
existing.cancel()
|
||||
timer = threading.Timer(
|
||||
debounce_s, self._switcher.apply_profile, args=[profile_id]
|
||||
)
|
||||
self._debounce_timers[key] = timer
|
||||
timer.start()
|
||||
34
core/hotkey_manager.py
Normal file
34
core/hotkey_manager.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import logging
|
||||
import keyboard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HotkeyManager:
|
||||
def __init__(self, config_manager, switcher):
|
||||
self._config_manager = config_manager
|
||||
self._switcher = switcher
|
||||
self._registered: list[str] = []
|
||||
|
||||
def register_all(self) -> None:
|
||||
self.unregister_all()
|
||||
config = self._config_manager.get()
|
||||
for profile in config.get("profiles", []):
|
||||
hotkey = profile.get("hotkey", "").strip()
|
||||
if not hotkey:
|
||||
continue
|
||||
profile_id = profile["id"]
|
||||
try:
|
||||
keyboard.add_hotkey(hotkey, self._switcher.apply_profile, args=[profile_id])
|
||||
self._registered.append(hotkey)
|
||||
logger.info("Hotkey '%s' -> profile '%s'", hotkey, profile_id)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to register hotkey '%s': %s", hotkey, exc)
|
||||
|
||||
def unregister_all(self) -> None:
|
||||
for hotkey in self._registered:
|
||||
try:
|
||||
keyboard.remove_hotkey(hotkey)
|
||||
except Exception:
|
||||
pass
|
||||
self._registered.clear()
|
||||
54
core/switcher.py
Normal file
54
core/switcher.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import logging
|
||||
from typing import Callable
|
||||
from monitorcontrol import get_monitors
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VCP_INPUT_SOURCE = 0x60
|
||||
|
||||
|
||||
class MonitorSwitcher:
|
||||
def __init__(self, config_manager):
|
||||
self._config_manager = config_manager
|
||||
self._on_switch_callbacks: list[Callable[[str], None]] = []
|
||||
|
||||
def apply_profile(self, profile_id: str) -> None:
|
||||
profile = self._config_manager.get_profile(profile_id)
|
||||
if profile is None:
|
||||
logger.warning("Profile not found: %s", profile_id)
|
||||
return
|
||||
|
||||
monitors = list(get_monitors())
|
||||
for input_cfg in profile["monitor_inputs"]:
|
||||
idx = input_cfg["monitor_index"]
|
||||
vcp_value = input_cfg["vcp_value"]
|
||||
if idx >= len(monitors):
|
||||
logger.warning(
|
||||
"Monitor index %d out of range (have %d)", idx, len(monitors)
|
||||
)
|
||||
continue
|
||||
try:
|
||||
with monitors[idx] as monitor:
|
||||
monitor.set_vcp_feature(VCP_INPUT_SOURCE, vcp_value)
|
||||
logger.info("Monitor %d -> VCP input %d", idx, vcp_value)
|
||||
except Exception as exc:
|
||||
logger.warning("Monitor %d DDC/CI error: %s", idx, exc)
|
||||
|
||||
self._config_manager.set_active_profile(profile_id)
|
||||
for cb in self._on_switch_callbacks:
|
||||
cb(profile_id)
|
||||
|
||||
def on_switch(self, callback: Callable[[str], None]) -> None:
|
||||
self._on_switch_callbacks.append(callback)
|
||||
|
||||
def list_monitors(self) -> list[dict]:
|
||||
result = []
|
||||
for idx, monitor_handle in enumerate(get_monitors()):
|
||||
try:
|
||||
with monitor_handle as monitor:
|
||||
current = monitor.get_vcp_feature(VCP_INPUT_SOURCE)
|
||||
result.append({"index": idx, "current_vcp": current.value})
|
||||
except Exception as exc:
|
||||
logger.warning("Cannot query monitor %d: %s", idx, exc)
|
||||
result.append({"index": idx, "current_vcp": None})
|
||||
return result
|
||||
Reference in New Issue
Block a user