6 Commits

Author SHA1 Message Date
8bed09a401 perf: pre-compile Python bytecode in portable build
Some checks failed
Build Release / create-release (push) Successful in 1s
Lint & Test / test (push) Failing after 24s
Build Release / build-linux (push) Successful in 1m11s
Build Release / build-docker (push) Successful in 8s
Build Release / build-windows (push) Successful in 2m51s
Add compileall step to build-dist-windows.sh that generates .pyc files
for both app source and site-packages. Saves ~100-200ms on startup by
skipping parse/compile on first import.
2026-03-22 13:38:15 +03:00
6a6c8b2c52 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.
2026-03-22 13:35:01 +03:00
4aa209f7d1 perf: strip OpenCV ffmpeg DLL and PyWin32 help from portable build
Some checks failed
Lint & Test / test (push) Failing after 14s
- Remove opencv_videoio_ffmpeg (28MB) — only needed for video file I/O,
  camera capture uses cv2.VideoCapture which links directly to DirectShow
- Remove PyWin32.chm help file (2.6MB)
- Keep cv2.pyd intact (needed for resize, cvtColor, camera)

Future: migrate non-camera cv2 usage to Pillow, replace winsdk (37MB
monolithic binary) with lighter notification API.
2026-03-22 03:49:55 +03:00
14adc8172b perf: reduce Windows portable build size by ~80MB
Some checks failed
Lint & Test / test (push) Failing after 28s
Strip unnecessary files from site-packages:
- Remove pip, setuptools, pythonwin (not needed at runtime)
- OpenCV: remove unused extra modules and data
- numpy: remove tests, f2py, typing stubs
- Remove all .dist-info directories and .pyi type stubs
- Remove winsdk type stubs
2026-03-22 03:46:20 +03:00
0e54616000 fix: use msiextract for tkinter, fix step numbering, graceful Docker fallback
Some checks failed
Build Release / create-release (push) Successful in 0s
Lint & Test / test (push) Failing after 14s
Build Release / build-linux (push) Successful in 1m27s
Build Release / build-docker (push) Successful in 6s
Build Release / build-windows (push) Successful in 3m26s
- Replace 7z with msiextract (msitools) to extract tkinter from
  python.org's individual MSI packages (tcltk.msi + lib.msi)
- Fix build step numbering to /9
- Docker job continues on login failure (registry may not be enabled)
- Show makensis output for debugging
2026-03-22 03:44:37 +03:00
3633793972 fix: extract tkinter from Python installer via 7z, fix NSIS icon path
Some checks failed
Build Release / create-release (push) Successful in 1s
Lint & Test / test (push) Failing after 15s
Build Release / build-linux (push) Successful in 1m20s
Build Release / build-docker (push) Failing after 9s
Build Release / build-windows (push) Successful in 3m19s
- Replace nuget approach (doesn't contain tkinter) with extracting
  from the official Python amd64.exe installer using 7z
- Remove MUI_ICON/MUI_UNICON (no .ico file available, use NSIS default)
- Add p7zip-full to CI dependencies
2026-03-22 03:40:06 +03:00
15 changed files with 367 additions and 142 deletions

View File

@@ -63,7 +63,7 @@ jobs:
- name: Install system dependencies - name: Install system dependencies
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y --no-install-recommends zip libportaudio2 nsis sudo apt-get install -y --no-install-recommends zip libportaudio2 nsis msitools
- name: Cross-build Windows distribution - name: Cross-build Windows distribution
run: | run: |
@@ -188,12 +188,15 @@ jobs:
echo "server_host=$SERVER_HOST" >> "$GITHUB_OUTPUT" echo "server_host=$SERVER_HOST" >> "$GITHUB_OUTPUT"
- name: Login to Gitea Container Registry - name: Login to Gitea Container Registry
id: docker-login
continue-on-error: true
run: | run: |
echo "${{ secrets.GITEA_TOKEN }}" | docker login \ echo "${{ secrets.GITEA_TOKEN }}" | docker login \
"${{ steps.meta.outputs.server_host }}" \ "${{ steps.meta.outputs.server_host }}" \
-u "${{ gitea.actor }}" --password-stdin -u "${{ gitea.actor }}" --password-stdin
- name: Build Docker image - name: Build Docker image
if: steps.docker-login.outcome == 'success'
run: | run: |
TAG="${{ gitea.ref_name }}" TAG="${{ gitea.ref_name }}"
REGISTRY="${{ steps.meta.outputs.registry }}" REGISTRY="${{ steps.meta.outputs.registry }}"
@@ -211,6 +214,7 @@ jobs:
fi fi
- name: Push Docker image - name: Push Docker image
if: steps.docker-login.outcome == 'success'
run: | run: |
TAG="${{ gitea.ref_name }}" TAG="${{ gitea.ref_name }}"
REGISTRY="${{ steps.meta.outputs.registry }}" REGISTRY="${{ steps.meta.outputs.registry }}"

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

@@ -47,7 +47,7 @@ echo ""
# ── Clean ──────────────────────────────────────────────────── # ── Clean ────────────────────────────────────────────────────
if [ -d "$DIST_DIR" ]; then if [ -d "$DIST_DIR" ]; then
echo "[1/8] Cleaning previous build..." echo "[1/9] Cleaning previous build..."
rm -rf "$DIST_DIR" rm -rf "$DIST_DIR"
fi fi
mkdir -p "$DIST_DIR" mkdir -p "$DIST_DIR"
@@ -57,7 +57,7 @@ mkdir -p "$DIST_DIR"
PYTHON_ZIP_URL="https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip" PYTHON_ZIP_URL="https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip"
PYTHON_ZIP_PATH="$BUILD_DIR/python-embed-win.zip" PYTHON_ZIP_PATH="$BUILD_DIR/python-embed-win.zip"
echo "[2/8] Downloading Windows embedded Python ${PYTHON_VERSION}..." echo "[2/9] Downloading Windows embedded Python ${PYTHON_VERSION}..."
if [ ! -f "$PYTHON_ZIP_PATH" ]; then if [ ! -f "$PYTHON_ZIP_PATH" ]; then
curl -sL "$PYTHON_ZIP_URL" -o "$PYTHON_ZIP_PATH" curl -sL "$PYTHON_ZIP_URL" -o "$PYTHON_ZIP_PATH"
fi fi
@@ -66,7 +66,7 @@ unzip -qo "$PYTHON_ZIP_PATH" -d "$PYTHON_DIR"
# ── Patch ._pth to enable site-packages ────────────────────── # ── Patch ._pth to enable site-packages ──────────────────────
echo "[3/8] Patching Python path configuration..." echo "[3/9] Patching Python path configuration..."
PTH_FILE=$(ls "$PYTHON_DIR"/python*._pth 2>/dev/null | head -1) PTH_FILE=$(ls "$PYTHON_DIR"/python*._pth 2>/dev/null | head -1)
if [ -z "$PTH_FILE" ]; then if [ -z "$PTH_FILE" ]; then
echo "ERROR: Could not find python*._pth in $PYTHON_DIR" >&2 echo "ERROR: Could not find python*._pth in $PYTHON_DIR" >&2
@@ -86,76 +86,85 @@ fi
echo " Patched $(basename "$PTH_FILE")" echo " Patched $(basename "$PTH_FILE")"
# ── Bundle tkinter into embedded Python ─────────────────────── # ── Bundle tkinter into embedded Python ───────────────────────
# Embedded Python doesn't include tkinter. We download it from the # Embedded Python doesn't include tkinter. We download the individual
# official Windows Python nuget package (same version) which contains # MSI packages from python.org (tcltk.msi + lib.msi) and extract them
# the _tkinter.pyd, tkinter/ package, and Tcl/Tk DLLs. # using msiextract (from msitools).
echo "[3b/8] Bundling tkinter for screen overlay support..." echo "[4/9] Bundling tkinter for screen overlay support..."
# Python minor version for nuget package (e.g., 3.11.9 -> 3.11) TK_EXTRACT="$BUILD_DIR/tk-extract"
PYTHON_MINOR="${PYTHON_VERSION%.*}" rm -rf "$TK_EXTRACT"
mkdir -p "$TK_EXTRACT"
# Download the full Python nuget package (contains all stdlib + DLLs) MSI_BASE="https://www.python.org/ftp/python/${PYTHON_VERSION}/amd64"
NUGET_URL="https://www.nuget.org/api/v2/package/python/${PYTHON_VERSION}"
NUGET_PKG="$BUILD_DIR/python-nuget.zip" # Download tcltk.msi (contains _tkinter.pyd, tcl/tk DLLs, tcl8.6/, tk8.6/)
if [ ! -f "$NUGET_PKG" ]; then TCLTK_MSI="$BUILD_DIR/tcltk.msi"
curl -sL "$NUGET_URL" -o "$NUGET_PKG" if [ ! -f "$TCLTK_MSI" ]; then
curl -sL "$MSI_BASE/tcltk.msi" -o "$TCLTK_MSI"
fi fi
NUGET_DIR="$BUILD_DIR/python-nuget" # Download lib.msi (contains tkinter/ Python package in the stdlib)
rm -rf "$NUGET_DIR" LIB_MSI="$BUILD_DIR/lib.msi"
mkdir -p "$NUGET_DIR" if [ ! -f "$LIB_MSI" ]; then
unzip -qo "$NUGET_PKG" -d "$NUGET_DIR" curl -sL "$MSI_BASE/lib.msi" -o "$LIB_MSI"
# Copy _tkinter.pyd (the C extension)
TKINTER_PYD=$(find "$NUGET_DIR" -name "_tkinter.pyd" | head -1)
if [ -n "$TKINTER_PYD" ]; then
cp "$TKINTER_PYD" "$PYTHON_DIR/"
echo " Copied _tkinter.pyd"
else
echo " WARNING: _tkinter.pyd not found in nuget package"
fi fi
# Copy tkinter Python package from the stdlib zip or Lib/ if command -v msiextract &>/dev/null; then
# The nuget package has Lib/tkinter/ # Extract both MSIs
TKINTER_PKG=$(find "$NUGET_DIR" -type d -name "tkinter" | head -1) (cd "$TK_EXTRACT" && msiextract "$TCLTK_MSI" 2>/dev/null)
if [ -n "$TKINTER_PKG" ]; then (cd "$TK_EXTRACT" && msiextract "$LIB_MSI" 2>/dev/null)
mkdir -p "$PYTHON_DIR/Lib"
cp -r "$TKINTER_PKG" "$PYTHON_DIR/Lib/tkinter"
echo " Copied tkinter/ package"
else
echo " WARNING: tkinter package not found in nuget package"
fi
# Copy Tcl/Tk DLLs (tcl86t.dll, tk86t.dll, etc.) # Copy _tkinter.pyd
for dll in tcl86t.dll tk86t.dll; do TKINTER_PYD=$(find "$TK_EXTRACT" -name "_tkinter.pyd" 2>/dev/null | head -1)
DLL_PATH=$(find "$NUGET_DIR" -name "$dll" | head -1) if [ -n "$TKINTER_PYD" ]; then
if [ -n "$DLL_PATH" ]; then cp "$TKINTER_PYD" "$PYTHON_DIR/"
cp "$DLL_PATH" "$PYTHON_DIR/" echo " Copied _tkinter.pyd"
echo " Copied $dll" else
echo " WARNING: _tkinter.pyd not found in tcltk.msi"
fi fi
done
# Copy Tcl/Tk data directories (tcl8.6, tk8.6) # Copy Tcl/Tk DLLs
for tcldir in tcl8.6 tk8.6; do for dll in tcl86t.dll tk86t.dll; do
TCL_PATH=$(find "$NUGET_DIR" -type d -name "$tcldir" | head -1) DLL_PATH=$(find "$TK_EXTRACT" -name "$dll" 2>/dev/null | head -1)
if [ -n "$TCL_PATH" ]; then if [ -n "$DLL_PATH" ]; then
cp -r "$TCL_PATH" "$PYTHON_DIR/$tcldir" cp "$DLL_PATH" "$PYTHON_DIR/"
echo " Copied $tcldir/" echo " Copied $dll"
fi
done
# Copy tkinter Python package
TKINTER_PKG=$(find "$TK_EXTRACT" -type d -name "tkinter" 2>/dev/null | head -1)
if [ -n "$TKINTER_PKG" ]; then
mkdir -p "$PYTHON_DIR/Lib"
cp -r "$TKINTER_PKG" "$PYTHON_DIR/Lib/tkinter"
echo " Copied tkinter/ package"
fi fi
done
# Copy tcl/tk data directories
for tcldir in tcl8.6 tk8.6; do
TCL_PATH=$(find "$TK_EXTRACT" -type d -name "$tcldir" 2>/dev/null | head -1)
if [ -n "$TCL_PATH" ]; then
cp -r "$TCL_PATH" "$PYTHON_DIR/$tcldir"
echo " Copied $tcldir/"
fi
done
echo " tkinter bundled successfully"
else
echo " WARNING: msiextract not found — skipping tkinter (install msitools)"
fi
# Add Lib to ._pth so tkinter package is importable # Add Lib to ._pth so tkinter package is importable
if ! grep -q '^Lib$' "$PTH_FILE"; then if ! grep -q '^Lib$' "$PTH_FILE"; then
echo 'Lib' >> "$PTH_FILE" echo 'Lib' >> "$PTH_FILE"
fi fi
rm -rf "$NUGET_DIR" rm -rf "$TK_EXTRACT"
echo " tkinter bundled successfully"
# ── Download pip and install into embedded Python ──────────── # ── Download pip and install into embedded Python ────────────
echo "[4/8] Installing pip into embedded Python..." echo "[5/9] Installing pip into embedded Python..."
SITE_PACKAGES="$PYTHON_DIR/Lib/site-packages" SITE_PACKAGES="$PYTHON_DIR/Lib/site-packages"
mkdir -p "$SITE_PACKAGES" mkdir -p "$SITE_PACKAGES"
@@ -177,7 +186,7 @@ done
# ── Download Windows wheels for all dependencies ───────────── # ── Download Windows wheels for all dependencies ─────────────
echo "[5/8] Downloading Windows dependencies..." echo "[6/9] Downloading Windows dependencies..."
WHEEL_DIR="$BUILD_DIR/win-wheels" WHEEL_DIR="$BUILD_DIR/win-wheels"
mkdir -p "$WHEEL_DIR" mkdir -p "$WHEEL_DIR"
@@ -213,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)
@@ -267,27 +279,65 @@ for sdist in "$WHEEL_DIR"/*.tar.gz; do
rm -rf "$TMPDIR" rm -rf "$TMPDIR"
done done
# Remove dist-info, caches, tests to reduce size # ── Reduce package size ────────────────────────────────────────
echo " Cleaning up to reduce size..."
# Remove caches, tests, docs, type stubs
find "$SITE_PACKAGES" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find "$SITE_PACKAGES" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -type d -name tests -exec rm -rf {} + 2>/dev/null || true find "$SITE_PACKAGES" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -type d -name test -exec rm -rf {} + 2>/dev/null || true find "$SITE_PACKAGES" -type d -name test -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -name "*.pyi" -delete 2>/dev/null || true
# Remove pip and setuptools (not needed at runtime)
rm -rf "$SITE_PACKAGES"/pip "$SITE_PACKAGES"/pip-* 2>/dev/null || true
rm -rf "$SITE_PACKAGES"/setuptools "$SITE_PACKAGES"/setuptools-* "$SITE_PACKAGES"/pkg_resources 2>/dev/null || true
rm -rf "$SITE_PACKAGES"/_distutils_hack 2>/dev/null || true
# Remove pythonwin GUI IDE and help file (ships with pywin32 but not needed)
rm -rf "$SITE_PACKAGES"/pythonwin 2>/dev/null || true
rm -f "$SITE_PACKAGES"/PyWin32.chm 2>/dev/null || true
# OpenCV: remove ffmpeg DLL (28MB, only for video file I/O, not camera),
# Haar cascades (2.6MB), and misc dev files
CV2_DIR="$SITE_PACKAGES/cv2"
if [ -d "$CV2_DIR" ]; then
rm -f "$CV2_DIR"/opencv_videoio_ffmpeg*.dll 2>/dev/null || true
rm -rf "$CV2_DIR/data" "$CV2_DIR/gapi" "$CV2_DIR/misc" "$CV2_DIR/utils" 2>/dev/null || true
rm -rf "$CV2_DIR/typing_stubs" "$CV2_DIR/typing" 2>/dev/null || true
fi
# numpy: remove tests, f2py, typing stubs
rm -rf "$SITE_PACKAGES/numpy/tests" "$SITE_PACKAGES/numpy/*/tests" 2>/dev/null || true
rm -rf "$SITE_PACKAGES/numpy/f2py" 2>/dev/null || true
rm -rf "$SITE_PACKAGES/numpy/typing" 2>/dev/null || true
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
# winrt: remove type stubs
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
CLEANED_SIZE=$(du -sh "$SITE_PACKAGES" | cut -f1)
echo " Site-packages after cleanup: $CLEANED_SIZE"
WHEEL_COUNT=$(ls "$WHEEL_DIR"/*.whl 2>/dev/null | wc -l) WHEEL_COUNT=$(ls "$WHEEL_DIR"/*.whl 2>/dev/null | wc -l)
echo " Installed $WHEEL_COUNT packages" echo " Installed $WHEEL_COUNT packages"
# ── Build frontend ─────────────────────────────────────────── # ── Build frontend ───────────────────────────────────────────
echo "[6/8] Building frontend bundle..." echo "[7/9] Building frontend bundle..."
(cd "$SERVER_DIR" && npm ci --loglevel error && npm run build) 2>&1 | { (cd "$SERVER_DIR" && npm ci --loglevel error && npm run build) 2>&1 | {
grep -v 'RemoteException' || true grep -v 'RemoteException' || true
} }
# ── Copy application files ─────────────────────────────────── # ── Copy application files ───────────────────────────────────
echo "[7/8] Copying application files..." echo "[8/9] Copying application files..."
mkdir -p "$APP_DIR" mkdir -p "$APP_DIR"
cp -r "$SERVER_DIR/src" "$APP_DIR/src" cp -r "$SERVER_DIR/src" "$APP_DIR/src"
@@ -298,9 +348,14 @@ mkdir -p "$DIST_DIR/data" "$DIST_DIR/logs"
find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true
find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
# Pre-compile Python bytecode for faster startup
echo " Pre-compiling Python bytecode..."
python -m compileall -b -q "$APP_DIR/src" 2>/dev/null || true
python -m compileall -b -q "$SITE_PACKAGES" 2>/dev/null || true
# ── Create launcher ────────────────────────────────────────── # ── Create launcher ──────────────────────────────────────────
echo "[8/8] Creating launcher and packaging..." echo "[8b/9] Creating launcher and packaging..."
cat > "$DIST_DIR/LedGrab.bat" << LAUNCHER cat > "$DIST_DIR/LedGrab.bat" << LAUNCHER
@echo off @echo off
@@ -397,8 +452,8 @@ SETUP_NAME="LedGrab-v${VERSION_CLEAN}-win-x64-setup.exe"
SETUP_PATH="$BUILD_DIR/$SETUP_NAME" SETUP_PATH="$BUILD_DIR/$SETUP_NAME"
if command -v makensis &>/dev/null; then if command -v makensis &>/dev/null; then
echo "[9/8] Building NSIS installer..." echo "[9/9] Building NSIS installer..."
makensis -DVERSION="${VERSION_CLEAN}" "$SCRIPT_DIR/installer.nsi" >/dev/null 2>&1 makensis -DVERSION="${VERSION_CLEAN}" "$SCRIPT_DIR/installer.nsi"
if [ -f "$SETUP_PATH" ]; then if [ -f "$SETUP_PATH" ]; then
SETUP_SIZE=$(du -h "$SETUP_PATH" | cut -f1) SETUP_SIZE=$(du -h "$SETUP_PATH" | cut -f1)
echo " Installer: $SETUP_PATH ($SETUP_SIZE)" echo " Installer: $SETUP_PATH ($SETUP_SIZE)"
@@ -406,7 +461,7 @@ if command -v makensis &>/dev/null; then
echo " WARNING: makensis ran but installer not found at $SETUP_PATH" echo " WARNING: makensis ran but installer not found at $SETUP_PATH"
fi fi
else else
echo "[9/8] Skipping installer (makensis not found — install nsis to enable)" echo "[9/9] Skipping installer (makensis not found — install nsis to enable)"
fi fi
echo "" echo ""

View File

@@ -30,8 +30,6 @@ SetCompressor /SOLID lzma
; ── Modern UI Configuration ───────────────────────────────── ; ── Modern UI Configuration ─────────────────────────────────
!define MUI_ABORTWARNING !define MUI_ABORTWARNING
!define MUI_ICON "server\src\wled_controller\static\icon-192.png"
!define MUI_UNICON "server\src\wled_controller\static\icon-192.png"
; ── Pages ─────────────────────────────────────────────────── ; ── Pages ───────────────────────────────────────────────────

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 {}