11 Commits

Author SHA1 Message Date
52c8614a3c fix: escape release body via Python to avoid YAML parsing errors
All checks were successful
Lint & Test / test (push) Successful in 31s
Build Release / create-release (push) Successful in 1s
Build Release / build-linux (push) Successful in 1m14s
Build Release / build-docker (push) Successful in 7s
Build Release / build-windows (push) Successful in 2m41s
2026-03-22 13:59:14 +03:00
5c814a64a7 fix: remove extraneous f-string prefixes in startup banner
All checks were successful
Lint & Test / test (push) Successful in 35s
2026-03-22 13:53:30 +03:00
0716d602e2 docs: add CI/CD context file and pre-commit lint rule
Some checks failed
Lint & Test / test (push) Failing after 13s
Create contexts/ci-cd.md documenting release pipeline, build scripts,
CI runners, and versioning. Reference it from CLAUDE.md context table.
Add mandatory pre-commit lint check rule to CLAUDE.md.
2026-03-22 13:52:47 +03:00
42bc05c968 fix: update release description to match current build artifacts
Some checks failed
Lint & Test / test (push) Failing after 14s
Add Windows installer, Docker volume mount, and first-time setup
instructions to the Gitea release body. Fix Docker registry URL.
Add CI/Release sync rule to CLAUDE.md.
2026-03-22 13:50:46 +03:00
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
7f799a914d feat: add NSIS Windows installer to release workflow
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 1m21s
Build Release / build-docker (push) Failing after 9s
Build Release / build-windows (push) Failing after 1m37s
- installer.nsi: per-user install to AppData, Start Menu shortcuts,
  optional desktop shortcut and autostart, clean uninstall (preserves
  data/), Add/Remove Programs registration
- build-dist-windows.sh: runs makensis after ZIP if available
- release.yml: install nsis in CI, upload both ZIP and setup.exe
- Fix Docker registry login (sed -E for https:// stripping)
2026-03-22 03:35:34 +03:00
17 changed files with 697 additions and 158 deletions

View File

@@ -25,13 +25,51 @@ jobs:
IS_PRE="true" IS_PRE="true"
fi fi
# Build registry path for Docker instructions
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed -E 's|https?://||')
REPO=$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')
DOCKER_IMAGE="${SERVER_HOST}/${REPO}"
# Build release body via Python to avoid YAML escaping issues
BODY_JSON=$(python3 -c "
import json, sys
tag = '$TAG'
image = '$DOCKER_IMAGE'
body = f'''## Downloads
| Platform | File | Description |
|----------|------|-------------|
| Windows (installer) | \`LedGrab-{tag}-setup.exe\` | Install with Start Menu shortcut, optional autostart, uninstaller |
| Windows (portable) | \`LedGrab-{tag}-win-x64.zip\` | Unzip anywhere, run LedGrab.bat |
| Linux | \`LedGrab-{tag}-linux-x64.tar.gz\` | Extract, run ./run.sh |
| Docker | See below | docker pull + docker run |
After starting, open **http://localhost:8080** in your browser.
### Docker
\`\`\`bash
docker pull {image}:{tag}
docker run -d --name ledgrab -p 8080:8080 -v ledgrab-data:/app/data {image}:{tag}
\`\`\`
### First-time setup
1. Change the default API key in config/default_config.yaml
2. Open http://localhost:8080 and discover your WLED devices
3. See INSTALLATION.md for detailed configuration
'''
import textwrap
print(json.dumps(textwrap.dedent(body).strip()))
")
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \ RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{ -d "{
\"tag_name\": \"$TAG\", \"tag_name\": \"$TAG\",
\"name\": \"LedGrab $TAG\", \"name\": \"LedGrab $TAG\",
\"body\": \"## Downloads\\n\\n| Platform | File | How to run |\\n|----------|------|------------|\\n| Windows | \`LedGrab-${TAG}-win-x64.zip\` | Unzip → run \`LedGrab.bat\` → open http://localhost:8080 |\\n| Linux | \`LedGrab-${TAG}-linux-x64.tar.gz\` | Extract → run \`./run.sh\` → open http://localhost:8080 |\\n| Docker | See below | \`docker pull\` → \`docker run\` |\\n\\n### Docker\\n\\n\`\`\`bash\\ndocker pull ${{ gitea.server_url }}/${{ gitea.repository }}:${TAG}\\ndocker run -d -p 8080:8080 ${{ gitea.server_url }}/${{ gitea.repository }}:${TAG}\\n\`\`\`\", \"body\": $BODY_JSON,
\"draft\": false, \"draft\": false,
\"prerelease\": $IS_PRE \"prerelease\": $IS_PRE
}") }")
@@ -63,37 +101,50 @@ 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 sudo apt-get install -y --no-install-recommends zip libportaudio2 nsis msitools
- name: Cross-build Windows distribution - name: Cross-build Windows distribution
run: | run: |
chmod +x build-dist-windows.sh chmod +x build-dist-windows.sh
./build-dist-windows.sh "${{ gitea.ref_name }}" ./build-dist-windows.sh "${{ gitea.ref_name }}"
- name: Upload build artifact - name: Upload build artifacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: LedGrab-${{ gitea.ref_name }}-win-x64 name: LedGrab-${{ gitea.ref_name }}-win-x64
path: build/LedGrab-*.zip path: |
build/LedGrab-*.zip
build/LedGrab-*-setup.exe
retention-days: 90 retention-days: 90
- name: Attach ZIP to release - name: Attach assets to release
env: env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: | run: |
TAG="${{ gitea.ref_name }}"
RELEASE_ID="${{ needs.create-release.outputs.release_id }}" RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}" BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
ZIP_FILE=$(ls build/LedGrab-*.zip | head -1)
ZIP_NAME=$(basename "$ZIP_FILE")
# Upload ZIP
ZIP_FILE=$(ls build/LedGrab-*.zip | head -1)
if [ -f "$ZIP_FILE" ]; then
curl -s -X POST \ curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$ZIP_NAME" \ "$BASE_URL/releases/$RELEASE_ID/assets?name=$(basename "$ZIP_FILE")" \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
--data-binary "@$ZIP_FILE" --data-binary "@$ZIP_FILE"
echo "Uploaded: $(basename "$ZIP_FILE")"
fi
echo "Uploaded: $ZIP_NAME" # Upload installer
SETUP_FILE=$(ls build/LedGrab-*-setup.exe 2>/dev/null | head -1)
if [ -f "$SETUP_FILE" ]; then
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$(basename "$SETUP_FILE")" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$SETUP_FILE"
echo "Uploaded: $(basename "$SETUP_FILE")"
fi
# ── Linux tarball ────────────────────────────────────────── # ── Linux tarball ──────────────────────────────────────────
build-linux: build-linux:
@@ -165,20 +216,25 @@ jobs:
run: | run: |
TAG="${{ gitea.ref_name }}" TAG="${{ gitea.ref_name }}"
VERSION="${TAG#v}" VERSION="${TAG#v}"
REGISTRY="${{ gitea.server_url }}/${{ gitea.repository }}" # Strip protocol and lowercase for Docker registry path
# Lowercase the registry path (Docker requires it) SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed -E 's|https?://||')
REGISTRY=$(echo "$REGISTRY" | tr '[:upper:]' '[:lower:]' | sed 's|https\?://||') REPO=$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')
REGISTRY="${SERVER_HOST}/${REPO}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "registry=$REGISTRY" >> "$GITHUB_OUTPUT" echo "registry=$REGISTRY" >> "$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 \
"$(echo '${{ gitea.server_url }}' | sed 's|https\?://||')" \ "${{ 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 }}"
@@ -196,6 +252,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

@@ -38,6 +38,7 @@ ast-index changed --base master # Show symbols changed in current bran
| [contexts/graph-editor.md](contexts/graph-editor.md) | Visual graph editor changes | | [contexts/graph-editor.md](contexts/graph-editor.md) | Visual graph editor changes |
| [contexts/server-operations.md](contexts/server-operations.md) | Server restart, startup modes, demo mode | | [contexts/server-operations.md](contexts/server-operations.md) | Server restart, startup modes, demo mode |
| [contexts/chrome-tools.md](contexts/chrome-tools.md) | Chrome MCP tool usage for testing | | [contexts/chrome-tools.md](contexts/chrome-tools.md) | Chrome MCP tool usage for testing |
| [contexts/ci-cd.md](contexts/ci-cd.md) | CI/CD pipelines, release workflow, build scripts |
| [server/CLAUDE.md](server/CLAUDE.md) | Backend architecture, API patterns, common tasks | | [server/CLAUDE.md](server/CLAUDE.md) | Backend architecture, API patterns, common tasks |
## Task Tracking via TODO.md ## Task Tracking via TODO.md
@@ -48,6 +49,32 @@ 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.
## Pre-Commit Checks (MANDATORY)
Before every commit, run the relevant linters and fix any issues:
- **Python changes**: `cd server && ruff check src/ tests/ --fix`
- **TypeScript changes**: `cd server && npx tsc --noEmit && npm run build`
- **Both**: Run both checks
Do NOT commit code that fails linting. Fix the issues first.
## 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"
fi
# Copy _tkinter.pyd (the C extension) if command -v msiextract &>/dev/null; then
TKINTER_PYD=$(find "$NUGET_DIR" -name "_tkinter.pyd" | head -1) # Extract both MSIs
(cd "$TK_EXTRACT" && msiextract "$TCLTK_MSI" 2>/dev/null)
(cd "$TK_EXTRACT" && msiextract "$LIB_MSI" 2>/dev/null)
# Copy _tkinter.pyd
TKINTER_PYD=$(find "$TK_EXTRACT" -name "_tkinter.pyd" 2>/dev/null | head -1)
if [ -n "$TKINTER_PYD" ]; then if [ -n "$TKINTER_PYD" ]; then
cp "$TKINTER_PYD" "$PYTHON_DIR/" cp "$TKINTER_PYD" "$PYTHON_DIR/"
echo " Copied _tkinter.pyd" echo " Copied _tkinter.pyd"
else else
echo " WARNING: _tkinter.pyd not found in nuget package" echo " WARNING: _tkinter.pyd not found in tcltk.msi"
fi fi
# Copy tkinter Python package from the stdlib zip or Lib/ # Copy Tcl/Tk DLLs
# The nuget package has Lib/tkinter/
TKINTER_PKG=$(find "$NUGET_DIR" -type d -name "tkinter" | head -1)
if [ -n "$TKINTER_PKG" ]; then
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.)
for dll in tcl86t.dll tk86t.dll; do for dll in tcl86t.dll tk86t.dll; do
DLL_PATH=$(find "$NUGET_DIR" -name "$dll" | head -1) DLL_PATH=$(find "$TK_EXTRACT" -name "$dll" 2>/dev/null | head -1)
if [ -n "$DLL_PATH" ]; then if [ -n "$DLL_PATH" ]; then
cp "$DLL_PATH" "$PYTHON_DIR/" cp "$DLL_PATH" "$PYTHON_DIR/"
echo " Copied $dll" echo " Copied $dll"
fi fi
done done
# Copy Tcl/Tk data directories (tcl8.6, tk8.6) # 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
# Copy tcl/tk data directories
for tcldir in tcl8.6 tk8.6; do for tcldir in tcl8.6 tk8.6; do
TCL_PATH=$(find "$NUGET_DIR" -type d -name "$tcldir" | head -1) TCL_PATH=$(find "$TK_EXTRACT" -type d -name "$tcldir" 2>/dev/null | head -1)
if [ -n "$TCL_PATH" ]; then if [ -n "$TCL_PATH" ]; then
cp -r "$TCL_PATH" "$PYTHON_DIR/$tcldir" cp -r "$TCL_PATH" "$PYTHON_DIR/$tcldir"
echo " Copied $tcldir/" echo " Copied $tcldir/"
fi fi
done 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
@@ -390,8 +445,29 @@ rm -f "$ZIP_PATH"
(cd "$BUILD_DIR" && zip -rq "$ZIP_NAME" "$DIST_NAME") (cd "$BUILD_DIR" && zip -rq "$ZIP_NAME" "$DIST_NAME")
ZIP_SIZE=$(du -h "$ZIP_PATH" | cut -f1) ZIP_SIZE=$(du -h "$ZIP_PATH" | cut -f1)
# ── Build NSIS installer (if makensis is available) ──────────
SETUP_NAME="LedGrab-v${VERSION_CLEAN}-win-x64-setup.exe"
SETUP_PATH="$BUILD_DIR/$SETUP_NAME"
if command -v makensis &>/dev/null; then
echo "[9/9] Building NSIS installer..."
makensis -DVERSION="${VERSION_CLEAN}" "$SCRIPT_DIR/installer.nsi"
if [ -f "$SETUP_PATH" ]; then
SETUP_SIZE=$(du -h "$SETUP_PATH" | cut -f1)
echo " Installer: $SETUP_PATH ($SETUP_SIZE)"
else
echo " WARNING: makensis ran but installer not found at $SETUP_PATH"
fi
else
echo "[9/9] Skipping installer (makensis not found — install nsis to enable)"
fi
echo "" echo ""
echo "=== Build complete ===" echo "=== Build complete ==="
echo " Archive: $ZIP_PATH" echo " ZIP: $ZIP_PATH ($ZIP_SIZE)"
echo " Size: $ZIP_SIZE" if [ -f "$SETUP_PATH" ]; then
echo " Installer: $SETUP_PATH ($SETUP_SIZE)"
fi
echo "" echo ""

80
contexts/ci-cd.md Normal file
View File

@@ -0,0 +1,80 @@
# CI/CD & Release Workflow
## Workflows
| File | Trigger | Purpose |
|------|---------|---------|
| `.gitea/workflows/test.yml` | Push/PR to master | Lint (ruff) + pytest |
| `.gitea/workflows/release.yml` | Tag `v*` | Build artifacts + create Gitea release |
## Release Pipeline (`release.yml`)
Four parallel jobs triggered by pushing a `v*` tag:
### 1. `create-release`
Creates the Gitea release with a description table listing all artifacts. **The description must stay in sync with actual build outputs** — if you add/remove/rename an artifact, update the body template here.
### 2. `build-windows` (cross-built from Linux)
- Runs `build-dist-windows.sh` on Ubuntu with NSIS + msitools
- Downloads Windows embedded Python 3.11 + pip wheels cross-platform
- Bundles tkinter from Python MSI via msiextract
- Builds frontend (`npm run build`)
- Pre-compiles Python bytecode (`compileall`)
- Produces: **`LedGrab-{tag}-win-x64.zip`** (portable) and **`LedGrab-{tag}-setup.exe`** (NSIS installer)
### 3. `build-linux`
- Runs `build-dist.sh` on Ubuntu
- Creates a venv, installs deps, builds frontend
- Produces: **`LedGrab-{tag}-linux-x64.tar.gz`**
### 4. `build-docker`
- Plain `docker build` + `docker push` (no Buildx — TrueNAS runners lack nested networking)
- Registry: `{gitea_host}/{repo}:{tag}`
- Tags: `v0.x.x`, `0.x.x`, and `latest` (stable only, not alpha/beta/rc)
## Build Scripts
| Script | Platform | Output |
|--------|----------|--------|
| `build-dist-windows.sh` | Linux → Windows cross-build | ZIP + NSIS installer |
| `build-dist.sh` | Linux native | tarball |
| `server/Dockerfile` | Docker | Container image |
## Release Versioning
- Tags: `v{major}.{minor}.{patch}` for stable, `v{major}.{minor}.{patch}-alpha.{n}` for pre-release
- Pre-release tags set `prerelease: true` on the Gitea release
- Docker `latest` tag only applied to stable releases
- Version in `server/pyproject.toml` should match the tag (without `v` prefix)
## CI Runners
- Two TrueNAS Gitea runners with `ubuntu` tags
- No Windows runner available — Windows builds are cross-compiled from Linux
- Docker Buildx not available (networking limitations) — use plain `docker build`
## Test Pipeline (`test.yml`)
- Installs `opencv-python-headless` and `libportaudio2` for CI compatibility
- Display-dependent tests are skipped via `@requires_display` marker
- Uses `python` not `python3` (Git Bash on Windows resolves `python3` to MS Store stub)
## Common Tasks
### Creating a release
```bash
git tag v0.2.0
git push origin v0.2.0
```
### Creating a pre-release
```bash
git tag v0.2.0-alpha.1
git push origin v0.2.0-alpha.1
```
### Adding a new build artifact
1. Update the build script to produce the new file
2. Add upload step in the relevant `build-*` job
3. **Update the release description** in `create-release` job body template
4. Test with a pre-release tag first

146
installer.nsi Normal file
View File

@@ -0,0 +1,146 @@
; LedGrab NSIS Installer Script
; Cross-compilable on Linux: apt install nsis && makensis installer.nsi
;
; Expects the portable build to already exist at build/LedGrab/
; (run build-dist-windows.sh first)
!include "MUI2.nsh"
!include "FileFunc.nsh"
; ── Metadata ────────────────────────────────────────────────
!define APPNAME "LedGrab"
!define DESCRIPTION "Ambient lighting system — captures screen content and drives LED strips in real time"
!define VERSIONMAJOR 0
!define VERSIONMINOR 1
!define VERSIONBUILD 0
; Set from command line: makensis -DVERSION=0.1.0 installer.nsi
!ifndef VERSION
!define VERSION "${VERSIONMAJOR}.${VERSIONMINOR}.${VERSIONBUILD}"
!endif
Name "${APPNAME} v${VERSION}"
OutFile "build\${APPNAME}-v${VERSION}-win-x64-setup.exe"
InstallDir "$LOCALAPPDATA\${APPNAME}"
InstallDirRegKey HKCU "Software\${APPNAME}" "InstallDir"
RequestExecutionLevel user
SetCompressor /SOLID lzma
; ── Modern UI Configuration ─────────────────────────────────
!define MUI_ABORTWARNING
; ── Pages ───────────────────────────────────────────────────
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_COMPONENTS
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English"
; ── Installer Sections ──────────────────────────────────────
Section "!${APPNAME} (required)" SecCore
SectionIn RO
SetOutPath "$INSTDIR"
; Copy the entire portable build
File /r "build\LedGrab\python"
File /r "build\LedGrab\app"
File "build\LedGrab\LedGrab.bat"
; Create data and logs directories
CreateDirectory "$INSTDIR\data"
CreateDirectory "$INSTDIR\logs"
; Create uninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
; Start Menu shortcuts
CreateDirectory "$SMPROGRAMS\${APPNAME}"
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" "$INSTDIR\LedGrab.bat" \
"" "" "" SW_SHOWMINIMIZED "" "${DESCRIPTION}"
CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" "$INSTDIR\uninstall.exe"
; Registry: install location + Add/Remove Programs entry
WriteRegStr HKCU "Software\${APPNAME}" "InstallDir" "$INSTDIR"
WriteRegStr HKCU "Software\${APPNAME}" "Version" "${VERSION}"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"DisplayName" "${APPNAME}"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"DisplayVersion" "${VERSION}"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"UninstallString" '"$INSTDIR\uninstall.exe"'
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"InstallLocation" "$INSTDIR"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"Publisher" "Alexei Dolgolyov"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"URLInfoAbout" "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"NoModify" 1
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"NoRepair" 1
; Calculate installed size for Add/Remove Programs
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"EstimatedSize" "$0"
SectionEnd
Section "Desktop shortcut" SecDesktop
CreateShortcut "$DESKTOP\${APPNAME}.lnk" "$INSTDIR\LedGrab.bat" \
"" "" "" SW_SHOWMINIMIZED "" "${DESCRIPTION}"
SectionEnd
Section "Start with Windows" SecAutostart
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" "$INSTDIR\LedGrab.bat" \
"" "" "" SW_SHOWMINIMIZED "" "${DESCRIPTION}"
SectionEnd
; ── Section Descriptions ────────────────────────────────────
!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN
!insertmacro MUI_DESCRIPTION_TEXT ${SecCore} \
"Install ${APPNAME} server and all required files."
!insertmacro MUI_DESCRIPTION_TEXT ${SecDesktop} \
"Create a shortcut on your desktop."
!insertmacro MUI_DESCRIPTION_TEXT ${SecAutostart} \
"Start ${APPNAME} automatically when you log in."
!insertmacro MUI_FUNCTION_DESCRIPTION_END
; ── Uninstaller ─────────────────────────────────────────────
Section "Uninstall"
; Remove shortcuts
Delete "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk"
Delete "$SMPROGRAMS\${APPNAME}\Uninstall.lnk"
RMDir "$SMPROGRAMS\${APPNAME}"
Delete "$DESKTOP\${APPNAME}.lnk"
Delete "$SMSTARTUP\${APPNAME}.lnk"
; Remove application files (but NOT data/ — preserve user config)
RMDir /r "$INSTDIR\python"
RMDir /r "$INSTDIR\app"
Delete "$INSTDIR\LedGrab.bat"
Delete "$INSTDIR\uninstall.exe"
; Remove logs (but keep data/)
RMDir /r "$INSTDIR\logs"
; Try to remove install dir (only succeeds if empty — data/ may remain)
RMDir "$INSTDIR"
; Remove registry keys
DeleteRegKey HKCU "Software\${APPNAME}"
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}"
SectionEnd

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

@@ -98,10 +98,10 @@ async def lifespan(app: FastAPI):
logger.info(f"Starting LED Grab v{__version__}") logger.info(f"Starting LED Grab v{__version__}")
logger.info(f"Python version: {sys.version}") logger.info(f"Python version: {sys.version}")
logger.info(f"Server listening on {config.server.host}:{config.server.port}") logger.info(f"Server listening on {config.server.host}:{config.server.port}")
print(f"\n =============================================") print("\n =============================================")
print(f" LED Grab v{__version__}") print(f" LED Grab v{__version__}")
print(f" Open http://localhost:{config.server.port} in your browser") print(f" Open http://localhost:{config.server.port} in your browser")
print(f" =============================================\n") print(" =============================================\n")
# Validate authentication configuration # Validate authentication configuration
if not config.auth.api_keys: if not config.auth.api_keys:

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