perf: reduce portable build size ~40MB — replace winsdk/wmi, migrate cv2 to Pillow
Some checks failed
Lint & Test / test (push) Failing after 19s

- 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.
This commit is contained in:
2026-03-22 13:35:01 +03:00
parent 4aa209f7d1
commit 6a6c8b2c52
13 changed files with 251 additions and 80 deletions

View File

@@ -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. **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 ## General Guidelines
- Always test changes before marking as complete - Always test changes before marking as complete

View File

@@ -222,9 +222,12 @@ DEPS=(
# Windows-only deps # Windows-only deps
WIN_DEPS=( WIN_DEPS=(
"wmi>=1.5.1"
"PyAudioWPatch>=0.2.12" "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) # 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 # Pillow: remove unused image plugins' test data
rm -rf "$SITE_PACKAGES/PIL/tests" 2>/dev/null || true rm -rf "$SITE_PACKAGES/PIL/tests" 2>/dev/null || true
# winsdk: remove type stubs and unused namespaces # winrt: remove type stubs
find "$SITE_PACKAGES/winsdk" -name "*.pyi" -delete 2>/dev/null || true find "$SITE_PACKAGES/winrt" -name "*.pyi" -delete 2>/dev/null || true
# Remove wled_controller if it got installed # Remove wled_controller if it got installed
rm -rf "$SITE_PACKAGES"/wled_controller* "$SITE_PACKAGES"/wled*.dist-info 2>/dev/null || true rm -rf "$SITE_PACKAGES"/wled_controller* "$SITE_PACKAGES"/wled*.dist-info 2>/dev/null || true

View File

@@ -37,7 +37,6 @@ dependencies = [
"python-dateutil>=2.9.0", "python-dateutil>=2.9.0",
"python-multipart>=0.0.12", "python-multipart>=0.0.12",
"jinja2>=3.1.0", "jinja2>=3.1.0",
"wmi>=1.5.1; sys_platform == 'win32'",
"zeroconf>=0.131.0", "zeroconf>=0.131.0",
"pyserial>=3.5", "pyserial>=3.5",
"psutil>=5.9.0", "psutil>=5.9.0",
@@ -61,9 +60,13 @@ dev = [
camera = [ camera = [
"opencv-python-headless>=4.8.0", "opencv-python-headless>=4.8.0",
] ]
# OS notification capture # OS notification capture (winrt packages are ~2.5MB total vs winsdk's ~35MB)
notifications = [ 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'", "dbus-next>=0.2.3; sys_platform == 'linux'",
] ]
# High-performance screen capture engines (Windows only) # High-performance screen capture engines (Windows only)

View File

@@ -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: 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.""" """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: if max_width and image.shape[1] > max_width:
scale = max_width / image.shape[1] scale = max_width / image.shape[1]
new_h = int(image.shape[0] * scale) new_h = int(image.shape[0] * scale)
image = cv2.resize(image, (max_width, new_h), interpolation=cv2.INTER_AREA) pil_img = pil_img.resize((max_width, new_h), Image.LANCZOS)
# RGB -> BGR for OpenCV JPEG encoding buf = io.BytesIO()
bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) pil_img.save(buf, format="JPEG", quality=quality)
_, buf = cv2.imencode('.jpg', bgr, [cv2.IMWRITE_JPEG_QUALITY, quality]) return buf.getvalue()
return buf.tobytes()
def _make_thumbnail(pil_image: Image.Image, max_width: int) -> Image.Image: def _make_thumbnail(pil_image: Image.Image, max_width: int) -> Image.Image:

View File

@@ -1,6 +1,6 @@
"""Platform-specific process and window detection. """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). Non-Windows: graceful degradation (returns empty results).
""" """
@@ -37,7 +37,7 @@ class PlatformDetector:
user32 = ctypes.windll.user32 user32 = ctypes.windll.user32
WNDPROC = ctypes.WINFUNCTYPE( WNDPROC = ctypes.WINFUNCTYPE(
ctypes.c_long, ctypes.c_ssize_t, # LRESULT (64-bit on x64)
ctypes.wintypes.HWND, ctypes.wintypes.HWND,
ctypes.c_uint, ctypes.c_uint,
ctypes.wintypes.WPARAM, ctypes.wintypes.WPARAM,
@@ -60,6 +60,12 @@ class PlatformDetector:
0x8F, 0x24, 0xC2, 0x8D, 0x93, 0x6F, 0xDA, 0x47, 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): def wnd_proc(hwnd, msg, wparam, lparam):
if msg == WM_POWERBROADCAST and wparam == PBT_POWERSETTINGCHANGE: if msg == WM_POWERBROADCAST and wparam == PBT_POWERSETTINGCHANGE:
try: try:

View File

@@ -56,21 +56,115 @@ def _cv2_backend_id(backend_name: str) -> Optional[int]:
def _get_camera_friendly_names() -> Dict[int, str]: def _get_camera_friendly_names() -> Dict[int, str]:
"""Get friendly names for cameras from OS. """Get friendly names for cameras from OS.
On Windows, queries WMI for PnP camera devices. On Windows, enumerates camera devices via the SetupAPI (pure ctypes,
Returns a dict mapping sequential index → friendly name. 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": if platform.system() != "Windows":
return {} return {}
try: try:
import wmi import ctypes
c = wmi.WMI() from ctypes import wintypes
cameras = c.query(
"SELECT Name FROM Win32_PnPEntity WHERE PNPClass = 'Camera'" # ── 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: except Exception as e:
logger.debug(f"WMI camera enumeration failed: {e}") logger.debug(f"SetupAPI camera enumeration failed: {e}")
return {} return {}

View File

@@ -3,7 +3,6 @@
import math import math
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import cv2
import numpy as np import numpy as np
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
@@ -69,12 +68,12 @@ class ColorCorrectionFilter(PostprocessingFilter):
g_mult = (tg / _REF_G) * gg g_mult = (tg / _REF_G) * gg
b_mult = (tb / _REF_B) * bg 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) src = np.arange(256, dtype=np.float32)
lut_r = np.clip(src * r_mult, 0, 255).astype(np.uint8) 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_g = np.clip(src * g_mult, 0, 255).astype(np.uint8)
lut_b = np.clip(src * b_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) 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]: def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
if self._is_neutral: if self._is_neutral:
return None return None
cv2.LUT(image, self._lut, dst=image) image[:] = self._lut[image]
return None return None

View File

@@ -2,8 +2,8 @@
from typing import List, Optional from typing import List, Optional
import cv2
import numpy as np import numpy as np
from PIL import Image
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
from wled_controller.core.filters.image_pool import ImagePool from wled_controller.core.filters.image_pool import ImagePool
@@ -44,7 +44,8 @@ class DownscalerFilter(PostprocessingFilter):
if new_h == h and new_w == w: if new_h == h and new_w == w:
return None 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) result = image_pool.acquire(new_h, new_w, image.shape[2] if image.ndim == 3 else 3)
np.copyto(result, downscaled) np.copyto(result, downscaled)

View File

@@ -2,8 +2,8 @@
from typing import List, Optional from typing import List, Optional
import cv2
import numpy as np import numpy as np
from PIL import Image
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
from wled_controller.core.filters.image_pool import ImagePool from wled_controller.core.filters.image_pool import ImagePool
@@ -42,8 +42,9 @@ class PixelateFilter(PostprocessingFilter):
# vectorized C++ instead of per-block Python loop # vectorized C++ instead of per-block Python loop
small_w = max(1, w // block_size) small_w = max(1, w // block_size)
small_h = max(1, h // block_size) small_h = max(1, h // block_size)
small = cv2.resize(image, (small_w, small_h), interpolation=cv2.INTER_AREA) pil_img = Image.fromarray(image)
pixelated = cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST) small = pil_img.resize((small_w, small_h), Image.LANCZOS)
pixelated = np.array(small.resize((w, h), Image.NEAREST))
np.copyto(image, pixelated) np.copyto(image, pixelated)
return None return None

View File

@@ -9,8 +9,8 @@ import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
import cv2
import numpy as np import numpy as np
from PIL import Image
from wled_controller.core.processing.live_stream import LiveStream from wled_controller.core.processing.live_stream import LiveStream
from wled_controller.core.capture.screen_capture import ( 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() t0 = time.perf_counter()
# Downsample to working resolution — 144x fewer pixels at 1080p # 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 # Extract colors for each rectangle from the small image
n = len(rect_names) n = len(rect_names)

View File

@@ -5,7 +5,8 @@ instances when new notifications appear. Sources with os_listener=True are
monitored. monitored.
Supported platforms: 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) - **Linux**: monitors org.freedesktop.Notifications via D-Bus (dbus-next)
""" """
@@ -33,8 +34,34 @@ def get_os_notification_listener() -> Optional["OsNotificationListener"]:
# ── Platform backends ────────────────────────────────────────────────── # ── 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: class _WindowsBackend:
"""Polls Windows toast notifications via winsdk.""" """Polls Windows toast notifications via winrt (preferred) or winsdk."""
def __init__(self, on_notification): def __init__(self, on_notification):
self._on_notification = on_notification self._on_notification = on_notification
@@ -48,21 +75,22 @@ class _WindowsBackend:
if platform.system() != "Windows": if platform.system() != "Windows":
return False return False
try: try:
from winsdk.windows.ui.notifications.management import ( UNL, AccessStatus, _NK, backend = _import_winrt_notifications()
UserNotificationListener, listener = UNL.current
UserNotificationListenerAccessStatus,
)
listener = UserNotificationListener.current
status = listener.get_access_status() status = listener.get_access_status()
if status != UserNotificationListenerAccessStatus.ALLOWED: if status != AccessStatus.ALLOWED:
logger.warning( logger.warning(
f"OS notification listener: access denied (status={status}). " f"OS notification listener: access denied (status={status}). "
"Enable notification access in Windows Settings > Privacy > Notifications." "Enable notification access in Windows Settings > Privacy > Notifications."
) )
return False return False
logger.info(f"OS notification listener: using {backend} backend")
return True return True
except ImportError: 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 return False
except Exception as e: except Exception as e:
logger.warning(f"OS notification listener: Windows init error: {e}") logger.warning(f"OS notification listener: Windows init error: {e}")
@@ -84,10 +112,8 @@ class _WindowsBackend:
self._thread = None self._thread = None
def _poll_loop(self) -> None: def _poll_loop(self) -> None:
from winsdk.windows.ui.notifications.management import UserNotificationListener UNL, _AccessStatus, NotificationKinds, _backend = _import_winrt_notifications()
from winsdk.windows.ui.notifications import NotificationKinds listener = UNL.current
listener = UserNotificationListener.current
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
async def _get_notifications(): async def _get_notifications():

View File

@@ -15,7 +15,7 @@ import {
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.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 { openDisplayPicker, formatDisplayLabel } from './displays.ts';
import { import {
getEngineIcon, getEngineIcon,
@@ -500,7 +500,7 @@ export function _runTestViaWS(wsPath: string, queryParams: any = {}, firstMessag
} else if (msg.type === 'result') { } else if (msg.type === 'result') {
gotResult = true; gotResult = true;
hideOverlaySpinner(); hideOverlaySpinner();
(window as any).openLightbox(msg.full_image, buildTestStatsHtml(msg)); openLightbox(msg.full_image, buildTestStatsHtml(msg));
ws.close(); ws.close();
} else if (msg.type === 'error') { } else if (msg.type === 'error') {
hideOverlaySpinner(); hideOverlaySpinner();

View File

@@ -1,5 +1,6 @@
"""Utility functions for retrieving friendly monitor/display names.""" """Utility functions for retrieving friendly monitor/display names."""
import ctypes
import sys import sys
from typing import Dict from typing import Dict
@@ -11,7 +12,8 @@ logger = get_logger(__name__)
def get_monitor_names() -> Dict[int, str]: def get_monitor_names() -> Dict[int, str]:
"""Get friendly names for connected monitors. """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). On other platforms, returns empty dict (will fall back to generic names).
Returns: Returns:
@@ -22,47 +24,68 @@ def get_monitor_names() -> Dict[int, str]:
return {} return {}
try: try:
import wmi from ctypes import wintypes
w = wmi.WMI(namespace="wmi") class DISPLAY_DEVICEW(ctypes.Structure):
monitors = w.WmiMonitorID() _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): DISPLAY_DEVICE_ACTIVE = 0x00000001
try: DISPLAY_DEVICE_ATTACHED_TO_DESKTOP = 0x00000002
# Extract manufacturer name
manufacturer = ""
if monitor.ManufacturerName:
manufacturer = "".join(chr(c) for c in monitor.ManufacturerName if c != 0)
# Extract user-friendly name monitor_names: Dict[int, str] = {}
user_name = "" monitor_idx = 0
if monitor.UserFriendlyName:
user_name = "".join(chr(c) for c in monitor.UserFriendlyName if c != 0)
# Build friendly name # Enumerate display adapters (GPUs / virtual outputs)
if user_name: adapter_idx = 0
friendly_name = user_name.strip() while adapter_idx < 16: # safety limit
elif manufacturer: adapter = DISPLAY_DEVICEW()
friendly_name = f"{manufacturer.strip()} Monitor" adapter.cb = ctypes.sizeof(DISPLAY_DEVICEW)
else:
friendly_name = f"Display {idx}"
monitor_names[idx] = friendly_name if not user32.EnumDisplayDevicesW(None, adapter_idx, ctypes.byref(adapter), 0):
logger.debug(f"Monitor {idx}: {friendly_name}") break
adapter_idx += 1
except Exception as e: # Skip adapters not attached to the desktop
logger.debug(f"Failed to parse monitor {idx} name: {e}") if not (adapter.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP):
monitor_names[idx] = f"Display {idx}" 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 return monitor_names
except ImportError:
logger.debug("WMI library not available - install with: pip install wmi")
return {}
except Exception as e: 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 {} return {}