From 6a6c8b2c52528f0d66a91d0d6911e26bfc0daf7e Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 22 Mar 2026 13:35:01 +0300 Subject: [PATCH] =?UTF-8?q?perf:=20reduce=20portable=20build=20size=20~40M?= =?UTF-8?q?B=20=E2=80=94=20replace=20winsdk/wmi,=20migrate=20cv2=20to=20Pi?= =?UTF-8?q?llow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace winsdk (~35MB) with winrt packages (~2.5MB) for OS notification listener. API is identical, 93% size reduction. - Replace wmi (~3-5MB) with ctypes for monitor names (EnumDisplayDevicesW) and camera names (SetupAPI). Zero external dependency. - Migrate cv2.resize/imencode/LUT to Pillow/numpy in 5 files (filters, preview helpers, kc_target_processor). OpenCV only needed for camera and video stream now. - Fix DefWindowProcW ctypes overflow on 64-bit Python (pre-existing bug in platform_detector display power listener). - Fix openLightbox import in streams-capture-templates.ts (was using broken window cast instead of direct import). - Add mandatory data migration policy to CLAUDE.md after silent data loss incident from storage file rename without migration. --- CLAUDE.md | 15 +++ build-dist-windows.sh | 11 +- server/pyproject.toml | 9 +- .../api/routes/_preview_helpers.py | 11 +- .../core/automations/platform_detector.py | 10 +- .../core/capture_engines/camera_engine.py | 110 ++++++++++++++++-- .../core/filters/color_correction.py | 7 +- .../core/filters/downscaler.py | 5 +- .../wled_controller/core/filters/pixelate.py | 7 +- .../core/processing/kc_target_processor.py | 5 +- .../processing/os_notification_listener.py | 52 ++++++--- .../js/features/streams-capture-templates.ts | 4 +- .../wled_controller/utils/monitor_names.py | 85 +++++++++----- 13 files changed, 251 insertions(+), 80 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c9d69ba..02b5d36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,21 @@ Use `TODO.md` in the project root as the primary task tracker. **Do NOT use the **Use context7 MCP tools for library/framework documentation lookups** (FastAPI, OpenCV, Pydantic, yt-dlp, etc.) instead of relying on potentially outdated training data. +## Data Migration Policy (CRITICAL) + +**NEVER rename a storage file path, store key, entity ID prefix, or JSON field name without writing a migration.** User data lives in JSON files under `data/`. If the code starts reading from a new filename while the old file still has user data, THAT DATA IS SILENTLY LOST. + +When renaming any storage-related identifier: +1. **Add migration logic in `BaseJsonStore.__init__`** (or the specific store) that detects the old file/key and migrates data to the new name automatically on startup +2. **Log a clear warning** when migration happens so the user knows +3. **Keep the old file as a backup** after migration (rename to `.migrated` or similar) +4. **Test the migration** with both old-format and new-format data files +5. **Document the migration** in the commit message + +This applies to: file paths in `StorageConfig`, JSON root keys (e.g. `picture_targets` → `output_targets`), entity ID prefixes (e.g. `pt_` → `ot_`), and any field renames in dataclass models. + +**Incident context:** A past rename of `picture_targets.json` → `output_targets.json` was done without migration. The app created a new empty `output_targets.json` while the user's 7 targets sat unread in the old file. Data was silently lost. + ## General Guidelines - Always test changes before marking as complete diff --git a/build-dist-windows.sh b/build-dist-windows.sh index b6102a8..2f7ea11 100644 --- a/build-dist-windows.sh +++ b/build-dist-windows.sh @@ -222,9 +222,12 @@ DEPS=( # Windows-only deps WIN_DEPS=( - "wmi>=1.5.1" "PyAudioWPatch>=0.2.12" - "winsdk>=1.0.0b10" + "winrt-Windows.UI.Notifications>=3.0.0" + "winrt-Windows.UI.Notifications.Management>=3.0.0" + "winrt-Windows.Foundation>=3.0.0" + "winrt-Windows.Foundation.Collections>=3.0.0" + "winrt-Windows.ApplicationModel>=3.0.0" ) # Download cross-platform deps (prefer binary, allow source for pure Python) @@ -313,8 +316,8 @@ rm -rf "$SITE_PACKAGES/numpy/_pyinstaller" 2>/dev/null || true # Pillow: remove unused image plugins' test data rm -rf "$SITE_PACKAGES/PIL/tests" 2>/dev/null || true -# winsdk: remove type stubs and unused namespaces -find "$SITE_PACKAGES/winsdk" -name "*.pyi" -delete 2>/dev/null || true +# winrt: remove type stubs +find "$SITE_PACKAGES/winrt" -name "*.pyi" -delete 2>/dev/null || true # Remove wled_controller if it got installed rm -rf "$SITE_PACKAGES"/wled_controller* "$SITE_PACKAGES"/wled*.dist-info 2>/dev/null || true diff --git a/server/pyproject.toml b/server/pyproject.toml index 780318e..7c41a1d 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -37,7 +37,6 @@ dependencies = [ "python-dateutil>=2.9.0", "python-multipart>=0.0.12", "jinja2>=3.1.0", - "wmi>=1.5.1; sys_platform == 'win32'", "zeroconf>=0.131.0", "pyserial>=3.5", "psutil>=5.9.0", @@ -61,9 +60,13 @@ dev = [ camera = [ "opencv-python-headless>=4.8.0", ] -# OS notification capture +# OS notification capture (winrt packages are ~2.5MB total vs winsdk's ~35MB) notifications = [ - "winsdk>=1.0.0b10; sys_platform == 'win32'", + "winrt-Windows.UI.Notifications>=3.0.0; sys_platform == 'win32'", + "winrt-Windows.UI.Notifications.Management>=3.0.0; sys_platform == 'win32'", + "winrt-Windows.Foundation>=3.0.0; sys_platform == 'win32'", + "winrt-Windows.Foundation.Collections>=3.0.0; sys_platform == 'win32'", + "winrt-Windows.ApplicationModel>=3.0.0; sys_platform == 'win32'", "dbus-next>=0.2.3; sys_platform == 'linux'", ] # High-performance screen capture engines (Windows only) diff --git a/server/src/wled_controller/api/routes/_preview_helpers.py b/server/src/wled_controller/api/routes/_preview_helpers.py index fe9a386..8122673 100644 --- a/server/src/wled_controller/api/routes/_preview_helpers.py +++ b/server/src/wled_controller/api/routes/_preview_helpers.py @@ -43,15 +43,14 @@ def _encode_jpeg(pil_image: Image.Image, quality: int = 85) -> str: def encode_preview_frame(image: np.ndarray, max_width: int = None, quality: int = 80) -> bytes: """Encode a numpy RGB image to JPEG bytes, optionally downscaling.""" - import cv2 + pil_img = Image.fromarray(image) if max_width and image.shape[1] > max_width: scale = max_width / image.shape[1] new_h = int(image.shape[0] * scale) - image = cv2.resize(image, (max_width, new_h), interpolation=cv2.INTER_AREA) - # RGB -> BGR for OpenCV JPEG encoding - bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) - _, buf = cv2.imencode('.jpg', bgr, [cv2.IMWRITE_JPEG_QUALITY, quality]) - return buf.tobytes() + pil_img = pil_img.resize((max_width, new_h), Image.LANCZOS) + buf = io.BytesIO() + pil_img.save(buf, format="JPEG", quality=quality) + return buf.getvalue() def _make_thumbnail(pil_image: Image.Image, max_width: int) -> Image.Image: diff --git a/server/src/wled_controller/core/automations/platform_detector.py b/server/src/wled_controller/core/automations/platform_detector.py index c692d77..15d78c2 100644 --- a/server/src/wled_controller/core/automations/platform_detector.py +++ b/server/src/wled_controller/core/automations/platform_detector.py @@ -1,6 +1,6 @@ """Platform-specific process and window detection. -Windows: uses wmi for process listing, ctypes for foreground window detection. +Windows: uses ctypes for process listing and foreground window detection. Non-Windows: graceful degradation (returns empty results). """ @@ -37,7 +37,7 @@ class PlatformDetector: user32 = ctypes.windll.user32 WNDPROC = ctypes.WINFUNCTYPE( - ctypes.c_long, + ctypes.c_ssize_t, # LRESULT (64-bit on x64) ctypes.wintypes.HWND, ctypes.c_uint, ctypes.wintypes.WPARAM, @@ -60,6 +60,12 @@ class PlatformDetector: 0x8F, 0x24, 0xC2, 0x8D, 0x93, 0x6F, 0xDA, 0x47, ) + user32.DefWindowProcW.argtypes = [ + ctypes.wintypes.HWND, ctypes.c_uint, + ctypes.wintypes.WPARAM, ctypes.wintypes.LPARAM, + ] + user32.DefWindowProcW.restype = ctypes.c_ssize_t + def wnd_proc(hwnd, msg, wparam, lparam): if msg == WM_POWERBROADCAST and wparam == PBT_POWERSETTINGCHANGE: try: diff --git a/server/src/wled_controller/core/capture_engines/camera_engine.py b/server/src/wled_controller/core/capture_engines/camera_engine.py index ecea963..2924c44 100644 --- a/server/src/wled_controller/core/capture_engines/camera_engine.py +++ b/server/src/wled_controller/core/capture_engines/camera_engine.py @@ -56,21 +56,115 @@ def _cv2_backend_id(backend_name: str) -> Optional[int]: def _get_camera_friendly_names() -> Dict[int, str]: """Get friendly names for cameras from OS. - On Windows, queries WMI for PnP camera devices. - Returns a dict mapping sequential index → friendly name. + On Windows, enumerates camera devices via the SetupAPI (pure ctypes, + no third-party dependencies). Uses the camera device class GUID + ``{ca3e7ab9-b4c3-4ae6-8251-579ef933890f}``. + + Returns a dict mapping sequential index to friendly name. """ if platform.system() != "Windows": return {} try: - import wmi - c = wmi.WMI() - cameras = c.query( - "SELECT Name FROM Win32_PnPEntity WHERE PNPClass = 'Camera'" + import ctypes + from ctypes import wintypes + + # ── SetupAPI types ──────────────────────────────────────── + class GUID(ctypes.Structure): + _fields_ = [ + ("Data1", wintypes.DWORD), + ("Data2", wintypes.WORD), + ("Data3", wintypes.WORD), + ("Data4", ctypes.c_ubyte * 8), + ] + + class SP_DEVINFO_DATA(ctypes.Structure): + _fields_ = [ + ("cbSize", wintypes.DWORD), + ("ClassGuid", GUID), + ("DevInst", wintypes.DWORD), + ("Reserved", ctypes.POINTER(ctypes.c_ulong)), + ] + + setupapi = ctypes.windll.setupapi + + # Camera device class GUID: {ca3e7ab9-b4c3-4ae6-8251-579ef933890f} + GUID_DEVCLASS_CAMERA = GUID( + 0xCA3E7AB9, 0xB4C3, 0x4AE6, + (ctypes.c_ubyte * 8)(0x82, 0x51, 0x57, 0x9E, 0xF9, 0x33, 0x89, 0x0F), ) - return {i: cam.Name for i, cam in enumerate(cameras)} + + DIGCF_PRESENT = 0x00000002 + SPDRP_FRIENDLYNAME = 0x0000000C + SPDRP_DEVICEDESC = 0x00000000 + INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value + + # SetupDiGetClassDevsW → HDEVINFO + setupapi.SetupDiGetClassDevsW.restype = ctypes.c_void_p + setupapi.SetupDiGetClassDevsW.argtypes = [ + ctypes.POINTER(GUID), ctypes.c_wchar_p, + ctypes.c_void_p, wintypes.DWORD, + ] + + # SetupDiEnumDeviceInfo → BOOL + setupapi.SetupDiEnumDeviceInfo.restype = wintypes.BOOL + setupapi.SetupDiEnumDeviceInfo.argtypes = [ + ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(SP_DEVINFO_DATA), + ] + + # SetupDiGetDeviceRegistryPropertyW → BOOL + setupapi.SetupDiGetDeviceRegistryPropertyW.restype = wintypes.BOOL + setupapi.SetupDiGetDeviceRegistryPropertyW.argtypes = [ + ctypes.c_void_p, ctypes.POINTER(SP_DEVINFO_DATA), + wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), + ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), + ] + + # SetupDiDestroyDeviceInfoList → BOOL + setupapi.SetupDiDestroyDeviceInfoList.restype = wintypes.BOOL + setupapi.SetupDiDestroyDeviceInfoList.argtypes = [ctypes.c_void_p] + + # ── Enumerate cameras ───────────────────────────────────── + hdevinfo = setupapi.SetupDiGetClassDevsW( + ctypes.byref(GUID_DEVCLASS_CAMERA), None, None, DIGCF_PRESENT, + ) + if hdevinfo == INVALID_HANDLE_VALUE: + return {} + + cameras: Dict[int, str] = {} + idx = 0 + + try: + while True: + devinfo = SP_DEVINFO_DATA() + devinfo.cbSize = ctypes.sizeof(SP_DEVINFO_DATA) + + if not setupapi.SetupDiEnumDeviceInfo(hdevinfo, idx, ctypes.byref(devinfo)): + break # ERROR_NO_MORE_ITEMS + + # Try SPDRP_FRIENDLYNAME first, fall back to SPDRP_DEVICEDESC + name = None + buf = ctypes.create_unicode_buffer(256) + buf_size = wintypes.DWORD(ctypes.sizeof(buf)) + + for prop in (SPDRP_FRIENDLYNAME, SPDRP_DEVICEDESC): + if setupapi.SetupDiGetDeviceRegistryPropertyW( + hdevinfo, ctypes.byref(devinfo), prop, + None, buf, buf_size, None, + ): + name = buf.value.strip() + if name: + break + + cameras[idx] = name if name else f"Camera {idx}" + idx += 1 + finally: + setupapi.SetupDiDestroyDeviceInfoList(hdevinfo) + + return cameras + except Exception as e: - logger.debug(f"WMI camera enumeration failed: {e}") + logger.debug(f"SetupAPI camera enumeration failed: {e}") return {} diff --git a/server/src/wled_controller/core/filters/color_correction.py b/server/src/wled_controller/core/filters/color_correction.py index 3de2482..df6f51e 100644 --- a/server/src/wled_controller/core/filters/color_correction.py +++ b/server/src/wled_controller/core/filters/color_correction.py @@ -3,7 +3,6 @@ import math from typing import Any, Dict, List, Optional -import cv2 import numpy as np from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter @@ -69,12 +68,12 @@ class ColorCorrectionFilter(PostprocessingFilter): g_mult = (tg / _REF_G) * gg b_mult = (tb / _REF_B) * bg - # Build merged (256, 1, 3) LUT for single-pass cv2.LUT + # Build merged (256, 3) LUT for single-pass numpy fancy-index lookup src = np.arange(256, dtype=np.float32) lut_r = np.clip(src * r_mult, 0, 255).astype(np.uint8) lut_g = np.clip(src * g_mult, 0, 255).astype(np.uint8) lut_b = np.clip(src * b_mult, 0, 255).astype(np.uint8) - self._lut = np.stack([lut_r, lut_g, lut_b], axis=-1).reshape(256, 1, 3) + self._lut = np.stack([lut_r, lut_g, lut_b], axis=-1) # (256, 3) self._is_neutral = (temp == 6500 and rg == 1.0 and gg == 1.0 and bg == 1.0) @@ -122,5 +121,5 @@ class ColorCorrectionFilter(PostprocessingFilter): def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: if self._is_neutral: return None - cv2.LUT(image, self._lut, dst=image) + image[:] = self._lut[image] return None diff --git a/server/src/wled_controller/core/filters/downscaler.py b/server/src/wled_controller/core/filters/downscaler.py index f0a0fc0..33fc52d 100644 --- a/server/src/wled_controller/core/filters/downscaler.py +++ b/server/src/wled_controller/core/filters/downscaler.py @@ -2,8 +2,8 @@ from typing import List, Optional -import cv2 import numpy as np +from PIL import Image from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter from wled_controller.core.filters.image_pool import ImagePool @@ -44,7 +44,8 @@ class DownscalerFilter(PostprocessingFilter): if new_h == h and new_w == w: return None - downscaled = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA) + pil_img = Image.fromarray(image) + downscaled = np.array(pil_img.resize((new_w, new_h), Image.LANCZOS)) result = image_pool.acquire(new_h, new_w, image.shape[2] if image.ndim == 3 else 3) np.copyto(result, downscaled) diff --git a/server/src/wled_controller/core/filters/pixelate.py b/server/src/wled_controller/core/filters/pixelate.py index f1836cb..fb5d302 100644 --- a/server/src/wled_controller/core/filters/pixelate.py +++ b/server/src/wled_controller/core/filters/pixelate.py @@ -2,8 +2,8 @@ from typing import List, Optional -import cv2 import numpy as np +from PIL import Image from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter from wled_controller.core.filters.image_pool import ImagePool @@ -42,8 +42,9 @@ class PixelateFilter(PostprocessingFilter): # vectorized C++ instead of per-block Python loop small_w = max(1, w // block_size) small_h = max(1, h // block_size) - small = cv2.resize(image, (small_w, small_h), interpolation=cv2.INTER_AREA) - pixelated = cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST) + pil_img = Image.fromarray(image) + small = pil_img.resize((small_w, small_h), Image.LANCZOS) + pixelated = np.array(small.resize((w, h), Image.NEAREST)) np.copyto(image, pixelated) return None diff --git a/server/src/wled_controller/core/processing/kc_target_processor.py b/server/src/wled_controller/core/processing/kc_target_processor.py index 4cd5bf3..ec3ea40 100644 --- a/server/src/wled_controller/core/processing/kc_target_processor.py +++ b/server/src/wled_controller/core/processing/kc_target_processor.py @@ -9,8 +9,8 @@ import time from datetime import datetime, timezone from typing import Dict, List, Optional, Tuple -import cv2 import numpy as np +from PIL import Image from wled_controller.core.processing.live_stream import LiveStream from wled_controller.core.capture.screen_capture import ( @@ -46,7 +46,8 @@ def _process_kc_frame(capture, rect_names, rect_bounds, calc_fn, prev_colors_arr t0 = time.perf_counter() # Downsample to working resolution — 144x fewer pixels at 1080p - small = cv2.resize(capture.image, KC_WORK_SIZE, interpolation=cv2.INTER_AREA) + pil_img = Image.fromarray(capture.image) + small = np.array(pil_img.resize(KC_WORK_SIZE, Image.LANCZOS)) # Extract colors for each rectangle from the small image n = len(rect_names) diff --git a/server/src/wled_controller/core/processing/os_notification_listener.py b/server/src/wled_controller/core/processing/os_notification_listener.py index 90791cd..20535f2 100644 --- a/server/src/wled_controller/core/processing/os_notification_listener.py +++ b/server/src/wled_controller/core/processing/os_notification_listener.py @@ -5,7 +5,8 @@ instances when new notifications appear. Sources with os_listener=True are monitored. Supported platforms: -- **Windows**: polls toast notifications via winsdk UserNotificationListener +- **Windows**: polls toast notifications via winrt UserNotificationListener + (falls back to winsdk if winrt packages are not installed) - **Linux**: monitors org.freedesktop.Notifications via D-Bus (dbus-next) """ @@ -33,8 +34,34 @@ def get_os_notification_listener() -> Optional["OsNotificationListener"]: # ── Platform backends ────────────────────────────────────────────────── +def _import_winrt_notifications(): + """Try to import WinRT notification APIs: winrt first, then winsdk fallback. + + Returns (UserNotificationListener, UserNotificationListenerAccessStatus, + NotificationKinds, backend_name) or raises ImportError. + """ + # Preferred: lightweight winrt packages (~1MB total) + try: + from winrt.windows.ui.notifications.management import ( + UserNotificationListener, + UserNotificationListenerAccessStatus, + ) + from winrt.windows.ui.notifications import NotificationKinds + return UserNotificationListener, UserNotificationListenerAccessStatus, NotificationKinds, "winrt" + except ImportError: + pass + + # Fallback: winsdk (~35MB, may already be installed) + from winsdk.windows.ui.notifications.management import ( + UserNotificationListener, + UserNotificationListenerAccessStatus, + ) + from winsdk.windows.ui.notifications import NotificationKinds + return UserNotificationListener, UserNotificationListenerAccessStatus, NotificationKinds, "winsdk" + + class _WindowsBackend: - """Polls Windows toast notifications via winsdk.""" + """Polls Windows toast notifications via winrt (preferred) or winsdk.""" def __init__(self, on_notification): self._on_notification = on_notification @@ -48,21 +75,22 @@ class _WindowsBackend: if platform.system() != "Windows": return False try: - from winsdk.windows.ui.notifications.management import ( - UserNotificationListener, - UserNotificationListenerAccessStatus, - ) - listener = UserNotificationListener.current + UNL, AccessStatus, _NK, backend = _import_winrt_notifications() + listener = UNL.current status = listener.get_access_status() - if status != UserNotificationListenerAccessStatus.ALLOWED: + if status != AccessStatus.ALLOWED: logger.warning( f"OS notification listener: access denied (status={status}). " "Enable notification access in Windows Settings > Privacy > Notifications." ) return False + logger.info(f"OS notification listener: using {backend} backend") return True except ImportError: - logger.info("OS notification listener: winsdk not installed, skipping") + logger.info( + "OS notification listener: neither winrt nor winsdk installed, skipping. " + "Install with: pip install winrt-Windows.UI.Notifications winrt-Windows.UI.Notifications.Management" + ) return False except Exception as e: logger.warning(f"OS notification listener: Windows init error: {e}") @@ -84,10 +112,8 @@ class _WindowsBackend: self._thread = None def _poll_loop(self) -> None: - from winsdk.windows.ui.notifications.management import UserNotificationListener - from winsdk.windows.ui.notifications import NotificationKinds - - listener = UserNotificationListener.current + UNL, _AccessStatus, NotificationKinds, _backend = _import_winrt_notifications() + listener = UNL.current loop = asyncio.new_event_loop() async def _get_notifications(): diff --git a/server/src/wled_controller/static/js/features/streams-capture-templates.ts b/server/src/wled_controller/static/js/features/streams-capture-templates.ts index e44e5cf..6afbc0b 100644 --- a/server/src/wled_controller/static/js/features/streams-capture-templates.ts +++ b/server/src/wled_controller/static/js/features/streams-capture-templates.ts @@ -15,7 +15,7 @@ import { import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts'; import { t } from '../core/i18n.ts'; import { Modal } from '../core/modal.ts'; -import { showToast, showConfirm, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setupBackdropClose } from '../core/ui.ts'; +import { showToast, showConfirm, openLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setupBackdropClose } from '../core/ui.ts'; import { openDisplayPicker, formatDisplayLabel } from './displays.ts'; import { getEngineIcon, @@ -500,7 +500,7 @@ export function _runTestViaWS(wsPath: string, queryParams: any = {}, firstMessag } else if (msg.type === 'result') { gotResult = true; hideOverlaySpinner(); - (window as any).openLightbox(msg.full_image, buildTestStatsHtml(msg)); + openLightbox(msg.full_image, buildTestStatsHtml(msg)); ws.close(); } else if (msg.type === 'error') { hideOverlaySpinner(); diff --git a/server/src/wled_controller/utils/monitor_names.py b/server/src/wled_controller/utils/monitor_names.py index 3068d61..436abce 100644 --- a/server/src/wled_controller/utils/monitor_names.py +++ b/server/src/wled_controller/utils/monitor_names.py @@ -1,5 +1,6 @@ """Utility functions for retrieving friendly monitor/display names.""" +import ctypes import sys from typing import Dict @@ -11,7 +12,8 @@ logger = get_logger(__name__) def get_monitor_names() -> Dict[int, str]: """Get friendly names for connected monitors. - On Windows, attempts to retrieve monitor names from WMI. + On Windows, enumerates display adapters and their monitors via + ``EnumDisplayDevicesW`` (pure ctypes, no third-party dependencies). On other platforms, returns empty dict (will fall back to generic names). Returns: @@ -22,47 +24,68 @@ def get_monitor_names() -> Dict[int, str]: return {} try: - import wmi + from ctypes import wintypes - w = wmi.WMI(namespace="wmi") - monitors = w.WmiMonitorID() + class DISPLAY_DEVICEW(ctypes.Structure): + _fields_ = [ + ("cb", wintypes.DWORD), + ("DeviceName", ctypes.c_wchar * 32), + ("DeviceString", ctypes.c_wchar * 128), + ("StateFlags", wintypes.DWORD), + ("DeviceID", ctypes.c_wchar * 128), + ("DeviceKey", ctypes.c_wchar * 128), + ] - monitor_names = {} + user32 = ctypes.windll.user32 - for idx, monitor in enumerate(monitors): - try: - # Extract manufacturer name - manufacturer = "" - if monitor.ManufacturerName: - manufacturer = "".join(chr(c) for c in monitor.ManufacturerName if c != 0) + DISPLAY_DEVICE_ACTIVE = 0x00000001 + DISPLAY_DEVICE_ATTACHED_TO_DESKTOP = 0x00000002 - # Extract user-friendly name - user_name = "" - if monitor.UserFriendlyName: - user_name = "".join(chr(c) for c in monitor.UserFriendlyName if c != 0) + monitor_names: Dict[int, str] = {} + monitor_idx = 0 - # Build friendly name - if user_name: - friendly_name = user_name.strip() - elif manufacturer: - friendly_name = f"{manufacturer.strip()} Monitor" - else: - friendly_name = f"Display {idx}" + # Enumerate display adapters (GPUs / virtual outputs) + adapter_idx = 0 + while adapter_idx < 16: # safety limit + adapter = DISPLAY_DEVICEW() + adapter.cb = ctypes.sizeof(DISPLAY_DEVICEW) - monitor_names[idx] = friendly_name - logger.debug(f"Monitor {idx}: {friendly_name}") + if not user32.EnumDisplayDevicesW(None, adapter_idx, ctypes.byref(adapter), 0): + break + adapter_idx += 1 - except Exception as e: - logger.debug(f"Failed to parse monitor {idx} name: {e}") - monitor_names[idx] = f"Display {idx}" + # Skip adapters not attached to the desktop + if not (adapter.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP): + continue + + # Enumerate monitors attached to this adapter + child_idx = 0 + while child_idx < 16: # safety limit + monitor = DISPLAY_DEVICEW() + monitor.cb = ctypes.sizeof(DISPLAY_DEVICEW) + + if not user32.EnumDisplayDevicesW( + adapter.DeviceName, child_idx, ctypes.byref(monitor), 0 + ): + break + child_idx += 1 + + if not (monitor.StateFlags & DISPLAY_DEVICE_ACTIVE): + continue + + # DeviceString contains the friendly name (e.g. "DELL U2718Q") + friendly_name = monitor.DeviceString.strip() + if not friendly_name: + friendly_name = f"Display {monitor_idx}" + + monitor_names[monitor_idx] = friendly_name + logger.debug(f"Monitor {monitor_idx}: {friendly_name}") + monitor_idx += 1 return monitor_names - except ImportError: - logger.debug("WMI library not available - install with: pip install wmi") - return {} except Exception as e: - logger.debug(f"Failed to retrieve monitor names via WMI: {e}") + logger.debug(f"Failed to retrieve monitor names via EnumDisplayDevices: {e}") return {}