Compare commits
7 Commits
v0.1.0-alp
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c814a64a7 | |||
| 0716d602e2 | |||
| 42bc05c968 | |||
| 8bed09a401 | |||
| 6a6c8b2c52 | |||
| 4aa209f7d1 | |||
| 14adc8172b |
@@ -25,13 +25,49 @@ 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
|
||||||
|
BODY=$(cat <<BODY_EOF
|
||||||
|
## 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 ${DOCKER_IMAGE}:${TAG}
|
||||||
|
docker run -d --name ledgrab -p 8080:8080 -v ledgrab-data:/app/data ${DOCKER_IMAGE}:${TAG}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### First-time setup
|
||||||
|
|
||||||
|
1. Change the default API key in \`config/default_config.yaml\` or set \`WLED_AUTH__API_KEYS='["your-secret-key"]'\`
|
||||||
|
2. Open http://localhost:8080 and discover your WLED devices
|
||||||
|
3. See [INSTALLATION.md](INSTALLATION.md) for detailed configuration
|
||||||
|
BODY_EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
# Escape body for JSON
|
||||||
|
BODY_JSON=$(echo "$BODY" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
|
||||||
|
|
||||||
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
|
||||||
}")
|
}")
|
||||||
|
|||||||
27
CLAUDE.md
27
CLAUDE.md
@@ -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
|
||||||
|
|||||||
@@ -222,9 +222,12 @@ DEPS=(
|
|||||||
|
|
||||||
# Windows-only deps
|
# Windows-only deps
|
||||||
WIN_DEPS=(
|
WIN_DEPS=(
|
||||||
"wmi>=1.5.1"
|
|
||||||
"PyAudioWPatch>=0.2.12"
|
"PyAudioWPatch>=0.2.12"
|
||||||
"winsdk>=1.0.0b10"
|
"winrt-Windows.UI.Notifications>=3.0.0"
|
||||||
|
"winrt-Windows.UI.Notifications.Management>=3.0.0"
|
||||||
|
"winrt-Windows.Foundation>=3.0.0"
|
||||||
|
"winrt-Windows.Foundation.Collections>=3.0.0"
|
||||||
|
"winrt-Windows.ApplicationModel>=3.0.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Download cross-platform deps (prefer binary, allow source for pure Python)
|
# Download cross-platform deps (prefer binary, allow source for pure Python)
|
||||||
@@ -276,14 +279,52 @@ 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"
|
||||||
|
|
||||||
@@ -307,6 +348,11 @@ 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 "[8b/9] Creating launcher and packaging..."
|
echo "[8b/9] Creating launcher and packaging..."
|
||||||
|
|||||||
80
contexts/ci-cd.md
Normal file
80
contexts/ci-cd.md
Normal 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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user