Compare commits

..

11 Commits

Author SHA1 Message Date
alexei.dolgolyov c44bb38c43 docs(release): refresh v0.4.2 notes with fix(release) and refactor commits
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 2m5s
Build Release / build-linux (push) Successful in 4m53s
Build Release / build-docker (push) Successful in 5m39s
Build Release / build-windows (push) Successful in 6m55s
Lint & Test / test (push) Successful in 7m13s
2026-04-22 20:20:30 +03:00
alexei.dolgolyov be2d5e1670 refactor(color-strips): move Key Colors test from lightbox into test-css-source modal
Lint & Test / test (push) Successful in 6m37s
Removes the inlined FPS select and auto-refresh button from the shared
image lightbox and rehosts the Key Colors live preview inside the
dedicated test-css-source modal alongside the other CSS test views.

- Drop initLightbox() / lightbox-fps-select IconSelect — the lightbox no
  longer owns streaming controls.
- Add #css-test-kc-view (canvas + meta) and .css-test-kc-* styles.
- Reroute _testKeyColorsSource() through the existing modal session
  lifecycle so KC, CSPT, and standard CSS tests share teardown paths.
2026-04-22 20:18:46 +03:00
alexei.dolgolyov 5db6eddcf8 fix(release): ship prebuilt assets and bump fallback version
Two release-blocking bugs traced to the same root cause: the unanchored
`data/` rule in .gitignore matched server/src/ledgrab/data/, which is
where shipped package assets live (prebuilt sounds, game adapters).
The files were never `git add`-able without -f, so they never reached
the v0.4.2 tag and CI builds couldn't include them.

- .gitignore: anchor /data/ and /server/data/ so nested package data
  dirs are not ignored.
- Track previously-excluded shipped assets:
  - server/src/ledgrab/data/prebuilt_sounds/{alert,bell,chime,ping,pop}.wav
  - server/src/ledgrab/data/game_adapters/{minecraft,rocket_league,valorant}.yaml
- Bump _FALLBACK_VERSION 0.3.0 -> 0.4.2 to match pyproject.toml.
  The Windows installer strips ledgrab-*.dist-info, so
  importlib.metadata falls back to this literal — which is why
  v0.4.2 reports v0.3.0 in the WebUI.
- Patch _FALLBACK_VERSION at bundle time in build-common.sh and
  build-dist.ps1 so future drift is auto-corrected by the build.
2026-04-22 20:17:10 +03:00
alexei.dolgolyov a8a4296a56 chore: release v0.4.2
Build Android APK / build-android (push) Failing after 1m48s
Build Release / create-release (push) Successful in 3s
Build Release / build-linux (push) Successful in 3m58s
Build Release / build-docker (push) Successful in 5m6s
Build Release / build-windows (push) Successful in 5m54s
Lint & Test / test (push) Successful in 6m14s
2026-04-22 19:48:37 +03:00
alexei.dolgolyov 9ce1dc33bf feat(ui): restyle enhanced header locale picker as LED-accent badge 2026-04-22 19:48:08 +03:00
alexei.dolgolyov 03d2e6b1f2 ci(release): publish .sha256 sidecars alongside release assets
Lint & Test / test (push) Successful in 2m4s
The in-app update service (`ledgrab.core.update.update_service`) refuses
to install any downloaded artifact that has no published sha256 — either
as a sibling `<asset>.sha256` asset on the Gitea release, or embedded in
the release body. The release workflow uploaded the ZIP, setup.exe, and
Linux tarball but never published checksums, so every auto-update 500'd
with "Update checksum unavailable; install aborted".

Generate sha256sum sidecars for the Windows ZIP, Windows setup.exe, and
Linux tar.gz and upload them next to the primary asset on each tagged
release. Existing v0.4.x releases stay broken — ship v0.4.2 (or manually
upload sidecars to v0.4.1) to unblock in-app updates.
2026-04-22 19:40:46 +03:00
alexei.dolgolyov c2c9af3c60 chore: release v0.4.1
Build Release / create-release (push) Successful in 4s
Build Android APK / build-android (push) Failing after 1m41s
Build Release / build-linux (push) Successful in 3m3s
Build Release / build-docker (push) Successful in 4m13s
Lint & Test / test (push) Successful in 5m24s
Build Release / build-windows (push) Successful in 5m33s
2026-04-22 19:21:27 +03:00
alexei.dolgolyov 4f7794ccd4 fix(installer): bundle cryptography + just-playback, set TCL env, clean stale debug.bat
Lint & Test / test (push) Successful in 2m20s
Windows installer silently failed to launch because build-dist-windows.sh
maintained its own DEPS list that drifted from server/pyproject.toml and
was missing `cryptography` — ledgrab.utils.secret_box imports AESGCM at
module load, so pythonw.exe crashed before the tray icon appeared. Also
missing: just-playback (lazy import, silent until a sound triggers).

- Add cryptography + just-playback to DEPS with a sync-with-pyproject
  warning comment
- Extend the post-cleanup on-disk check to abort the build if
  cryptography / cffi / just_playback go missing again
- Launcher now exports TCL_LIBRARY / TK_LIBRARY so the screen-overlay
  tkinter thread stops logging "Can't find init.tcl" at startup
- Installer wipes stale debug.bat / debug.log on install and uninstall
  (leftovers from the pre-rename wled_controller era produced a
  misleading ModuleNotFoundError when users tried to diagnose launch
  failures)
2026-04-22 19:19:07 +03:00
alexei.dolgolyov a0d63a3663 docs(release): drop stale WLED-rename task, document android signing secrets
Lint & Test / test (push) Successful in 2m1s
- Remove the top-of-file "IMPORTANT: Remove WLED naming throughout the
  app" checklist. The effort was absorbed by the multi-backend refactor
  (BLE / USB-serial / ESP-NOW / MQTT / OpenRGB providers all shipped),
  and the remaining user-facing copy has been swept in separate commits.
- Add an "Android Signing Secrets (Gitea)" section covering the four
  secrets the release APK CI expects, the one-off `keytool` command to
  generate `release.jks`, the consequences of losing the keystore, and
  a checklist of the remaining setup steps before tagging v0.4.1.
2026-04-21 20:01:26 +03:00
alexei.dolgolyov 35b75a2ed8 ci(android): fix keystore env scoping, fail loudly on release without key
Lint & Test / test (push) Successful in 3m17s
Primary bug — step-level env is not visible in that same step's `if:`
expression. `Decode signing keystore` had
  if: env.ANDROID_KEYSTORE_BASE64 != ''
  env:
    ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
so the env context seen by the `if:` evaluator was empty regardless of
whether the secret was configured. The step was skipped, keystore.present
never became 'true', and every release tag silently fell back to
assembleDebug. Result: APKs named `LedGrab-0.4.0-android-debug.apk` that
can't upgrade a previously-release-signed install (signature mismatch).

Fix — move ANDROID_KEYSTORE_BASE64 to the job-level env block. It's now
resolvable in the if-expression of any step in the job, and the shell
inherits it exactly the same way as before.

Secondary — add a "Guard release tag against missing keystore" step that
fires between the decode attempt and the gradle build. If is_release=true
but keystore.present!='true', the job fails with a clear error directing
the operator to configure the four signing secrets. Previously a
misconfigured Gitea silently shipped debug APKs labeled as releases.
2026-04-21 19:55:55 +03:00
alexei.dolgolyov 4ed099d564 docs(release): drop WLED-specific language from auto-generated release notes
The "discover your WLED devices" line predates BLE / USB-serial / ESP-NOW /
MQTT / OpenRGB support and misrepresents what the app does. Replaced with
a generic "add your LED devices" — the device-add UI lists what's actually
supported, and INSTALLATION.md carries the long-form detail.
2026-04-21 19:55:55 +03:00
28 changed files with 725 additions and 288 deletions
+17 -3
View File
@@ -23,6 +23,12 @@ jobs:
ANDROID_SDK_PLATFORM: 'android-34'
ANDROID_BUILD_TOOLS: '34.0.0'
ANDROID_NDK_VERSION: '26.1.10909125'
# Surfaced at job level (not step level) so the `if: env.X != ''`
# check on the Decode step actually sees it — step-level env is
# NOT available in that step's own `if:` expression, which
# silently skipped the decode and produced debug-signed release
# APKs until it was noticed.
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -108,15 +114,23 @@ jobs:
- name: Decode signing keystore
id: keystore
if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }}
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
if: env.ANDROID_KEYSTORE_BASE64 != ''
run: |
set -euo pipefail
mkdir -p android/keystore
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/keystore/release.jks
echo "path=$(pwd)/android/keystore/release.jks" >> "$GITHUB_OUTPUT"
echo "present=true" >> "$GITHUB_OUTPUT"
- name: Guard release tag against missing keystore
# Release tags MUST produce a release-signed APK, otherwise existing
# installs can't upgrade (signature mismatch). Fail loudly instead
# of silently falling back to the debug signing config.
if: ${{ steps.label.outputs.is_release == 'true' && steps.keystore.outputs.present != 'true' }}
run: |
echo "::error::Release tag ${{ gitea.ref_name }} requires ANDROID_KEYSTORE_BASE64 (plus KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD) to be configured in Gitea → Settings → Secrets."
exit 1
- name: Build APK
working-directory: android
env:
+40 -22
View File
@@ -90,9 +90,9 @@ jobs:
### 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
1. Change the default API key in `config/default_config.yaml`.
2. Open http://localhost:8080 and add your LED devices.
3. See `INSTALLATION.md` for detailed configuration.
''').strip())
print(json.dumps('\n\n'.join(sections)))
@@ -191,11 +191,21 @@ jobs:
echo "Uploaded: $NAME"
}
# Publish an asset plus its .sha256 sidecar. The in-app update
# service refuses to install without a published checksum, so
# every artifact needs its hash uploaded alongside.
upload_with_sha256() {
local FILE="$1"
upload_asset "$FILE"
(cd "$(dirname "$FILE")" && sha256sum "$(basename "$FILE")" > "$(basename "$FILE").sha256")
upload_asset "$FILE.sha256"
}
ZIP_FILE=$(ls build/LedGrab-*.zip | head -1)
[ -f "$ZIP_FILE" ] && upload_asset "$ZIP_FILE"
[ -f "$ZIP_FILE" ] && upload_with_sha256 "$ZIP_FILE"
SETUP_FILE=$(ls build/LedGrab-*-setup.exe 2>/dev/null | head -1)
[ -f "$SETUP_FILE" ] && upload_asset "$SETUP_FILE"
[ -f "$SETUP_FILE" ] && upload_with_sha256 "$SETUP_FILE"
# ── Linux tarball ──────────────────────────────────────────
build-linux:
@@ -242,26 +252,34 @@ jobs:
run: |
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
upload_asset() {
local FILE="$1"
local NAME
NAME=$(basename "$FILE")
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $GITEA_TOKEN" \
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$NAME'),''))" 2>/dev/null)
if [ -n "$EXISTING_ID" ]; then
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
-H "Authorization: token $GITEA_TOKEN"
echo "Replaced existing asset: $NAME"
fi
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$FILE"
echo "Uploaded: $NAME"
}
TAR_FILE=$(ls build/LedGrab-*.tar.gz | head -1)
TAR_NAME=$(basename "$TAR_FILE")
# Delete existing asset with same name to prevent duplicates on re-run
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $GITEA_TOKEN" \
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$TAR_NAME'),''))" 2>/dev/null)
if [ -n "$EXISTING_ID" ]; then
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
-H "Authorization: token $GITEA_TOKEN"
echo "Replaced existing asset: $TAR_NAME"
if [ -f "$TAR_FILE" ]; then
upload_asset "$TAR_FILE"
(cd "$(dirname "$TAR_FILE")" && sha256sum "$(basename "$TAR_FILE")" > "$(basename "$TAR_FILE").sha256")
upload_asset "$TAR_FILE.sha256"
fi
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$TAR_NAME" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$TAR_FILE"
echo "Uploaded: $TAR_NAME"
# ── Docker image ───────────────────────────────────────────
build-docker:
needs: create-release
+6 -2
View File
@@ -62,8 +62,12 @@ htmlcov/
logs/
*.log.*
# Runtime data
data/
# Runtime data — anchor to repo root so nested package data dirs
# (server/src/ledgrab/data/prebuilt_sounds, game_adapters) are NOT ignored.
# An unanchored `data/` rule silently broke the v0.4.2 release by keeping
# shipped sound assets out of the CI tag checkout.
/data/
/server/data/
*.db
*.sqlite
*.json.bak
+15 -88
View File
@@ -1,104 +1,31 @@
# v0.4.0 (2026-04-21)
## v0.4.2 (2026-04-22)
This release introduces a full **Android TV app** that embeds the Python server via Chaquopy, with boot-time autostart, root-based screen capture, and a watchdog. New device support includes **BLE LED controllers** (SP110E, Triones, Zengge, Govee), **Android USB-serial** Adalight/AmbiLED controllers, and a **Group** device type for combining multiple devices. Metrics now include battery and thermal-zone readings with a dashboard temperature chart. Devices get a new per-provider typed configuration model, and the project has been renamed from `led-grab` to **LedGrab** with the Home Assistant integration split into a separate repository.
### Bug Fixes
- Ship previously-missing package assets in release artifacts — prebuilt notification sounds (`alert`, `bell`, `chime`, `ping`, `pop`) and game adapter YAMLs (`minecraft`, `rocket_league`, `valorant`). An unanchored `data/` rule in `.gitignore` was matching `server/src/ledgrab/data/`, so these files never reached the tag or CI builds. Also bump the `_FALLBACK_VERSION` literal to `0.4.2` so the Windows installer (which strips `.dist-info`) reports the correct version in the WebUI instead of `0.3.0`. Build scripts now patch this literal automatically to prevent future drift. ([5db6edd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5db6edd))
## Features
### Android TV App
- Android TV app embedding Python server via Chaquopy ([8574424](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8574424))
- Boot-time autostart, capture watchdog, versionCode derived from git ([b3775b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b3775b2))
- Root-based screen capture bypassing MediaProjection ([5fcb9f8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5fcb9f8))
### Devices
- BLE LED controller support — SP110E, Triones, Zengge, Govee ([2b5dac2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2b5dac2))
- Android USB-serial support for Adalight/AmbiLED controllers ([7fcb8dd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7fcb8dd))
- **Group** device type for combining multiple devices ([4940007](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4940007))
- Per-provider typed device configs (phases 14) ([d3a6416](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d3a6416))
### Metrics
- Battery + thermal-zone readings with dashboard temperature chart ([ecae05d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ecae05d))
### Sources
- Support nesting for composite color strip sources ([cc9900d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cc9900d))
### Project
- Rename project to **LedGrab**; split Home Assistant integration into a separate repository ([02cd9d5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/02cd9d5))
## Bug Fixes
- SP110E vendor handshake + Windows/bleak robustness ([45f93fd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/45f93fd))
- Coerce `BindableFloat` fps to int when snapshotting scenes ([580bd69](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/580bd69))
- Add `autocomplete` attributes to credential inputs ([488df98](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/488df98))
- Register pattern-templates API route; responsive toolbar overflow menu ([38f73ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/38f73ba))
- HA Light Target cards no longer flicker on every poll cycle ([83ceaed](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/83ceaed))
- `EntitySelect` now shows the selected value in weather/processed CSS editors ([d3cd48e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d3cd48e))
- Bundle `bettercam`/`dxcam`/`windows-capture` in the Windows installer ([92585e7](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/92585e7))
- Launcher: set `TCL_LIBRARY`/`TK_LIBRARY` for embedded Python ([0e09eaf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0e09eaf))
- Comprehensive security, stability, and code quality audit ([123da1b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/123da1b))
### Features
- Restyle the enhanced header locale picker as a LED-accent badge — 2-letter code in Orbitron, collapses to just the badge on narrow screens ([9ce1dc3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ce1dc3))
---
## Development / Internal
### Development / Internal
### CI/Build
#### CI/Build
- Publish `.sha256` sidecars alongside release assets for easier integrity verification ([03d2e6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/03d2e6b))
- Android multi-ABI APK pipeline + `pydantic-core` wheel rebuild ([151cea3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/151cea3))
- Add Android APK row to release downloads table ([2477e00](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2477e00))
- Decouple Android release attach; add `workflow_dispatch` to `release.yml` ([524e422](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/524e422))
- Android: fix wheels find-links URL on Linux CI ([5d6310f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5d6310f))
- Android: fix missing python symlink parent; restrict to release tags ([7ef17c1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7ef17c1))
### Refactoring
- Route ESP-NOW client through `SerialTransport` ([928d626](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/928d626))
- `MetricsProvider` abstraction with Android `/proc` backend ([546b24d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/546b24d))
- Move build scripts to `build/` directory ([a0b65e3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a0b65e3))
### Documentation
- Update TODO and frontend context docs ([e678e55](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e678e55))
#### Refactoring
- Move the Key Colors test out of the lightbox and into the `test-css-source` modal where the rest of the source-render debug tools live ([be2d5e1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/be2d5e1))
---
<!-- markdownlint-disable MD033 -->
<details>
<summary>All Commits</summary>
| Hash | Message | Author |
| ---- | ------- | ------ |
| [524e422](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/524e422) | ci: decouple android release attach, add workflow_dispatch to release.yml | alexei.dolgolyov |
| [5d6310f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5d6310f) | fix(android): make wheels find-links URL work on Linux CI | alexei.dolgolyov |
| [7ef17c1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7ef17c1) | ci(android): fix missing python symlink parent, restrict to release tags | alexei.dolgolyov |
| [b3775b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b3775b2) | feat(android): boot-time autostart, capture watchdog, versionCode from git | alexei.dolgolyov |
| [45f93fd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/45f93fd) | fix(devices): SP110E vendor handshake + Windows/bleak robustness | alexei.dolgolyov |
| [2b5dac2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2b5dac2) | feat(devices): BLE LED controller support (SP110E/Triones/Zengge/Govee) | alexei.dolgolyov |
| [d3a6416](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d3a6416) | refactor(devices): per-provider typed configs (phases 1-4) | alexei.dolgolyov |
| [123da1b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/123da1b) | fix: comprehensive security, stability, and code quality audit | alexei.dolgolyov |
| [5fcb9f8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5fcb9f8) | feat(android): root-based screen capture bypassing MediaProjection | alexei.dolgolyov |
| [928d626](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/928d626) | refactor(devices): route ESP-NOW client through SerialTransport | alexei.dolgolyov |
| [580bd69](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/580bd69) | fix(scenes): coerce BindableFloat fps to int when snapshotting | alexei.dolgolyov |
| [7fcb8dd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7fcb8dd) | feat(devices): Android USB-serial support for Adalight/AmbiLED controllers | alexei.dolgolyov |
| [ecae05d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ecae05d) | feat(metrics): battery + thermal-zone readings with dashboard temp chart | alexei.dolgolyov |
| [546b24d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/546b24d) | refactor(metrics): MetricsProvider abstraction with Android /proc backend | alexei.dolgolyov |
| [488df98](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/488df98) | fix(frontend): add autocomplete attrs to credential inputs | alexei.dolgolyov |
| [2477e00](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2477e00) | ci: add Android APK row to release downloads table | alexei.dolgolyov |
| [151cea3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/151cea3) | ci: Android multi-ABI APK pipeline + pydantic-core wheel rebuild | alexei.dolgolyov |
| [8574424](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8574424) | feat: Android TV app embedding Python server via Chaquopy | alexei.dolgolyov |
| [a0b65e3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a0b65e3) | refactor: move build scripts to build/ directory | alexei.dolgolyov |
| [02cd9d5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/02cd9d5) | refactor: rename project to LedGrab, split HA integration into separate repo | alexei.dolgolyov |
| [38f73ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/38f73ba) | fix: register pattern-templates API route; add responsive toolbar overflow menu | alexei.dolgolyov |
| [e678e55](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e678e55) | docs: update TODO and frontend context docs | alexei.dolgolyov |
| [83ceaed](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/83ceaed) | fix: HA Light Target cards flickering on every poll cycle | alexei.dolgolyov |
| [d3cd48e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d3cd48e) | fix: EntitySelect not showing selected value in weather/processed CSS editors | alexei.dolgolyov |
| [cc9900d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cc9900d) | feat: support nesting for composite color strip sources | alexei.dolgolyov |
| [4940007](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4940007) | feat: add Group device type for combining multiple devices | alexei.dolgolyov |
| [92585e7](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/92585e7) | fix(build): bundle bettercam/dxcam/windows-capture in installer | alexei.dolgolyov |
| [0e09eaf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0e09eaf) | fix(launcher): set TCL_LIBRARY/TK_LIBRARY for embedded Python | alexei.dolgolyov |
|------|---------|--------|
| [be2d5e1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/be2d5e1) | refactor(color-strips): move Key Colors test from lightbox into test-css-source modal | alexei.dolgolyov |
| [5db6edd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5db6edd) | fix(release): ship prebuilt assets and bump fallback version | alexei.dolgolyov |
| [9ce1dc3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ce1dc3) | feat(ui): restyle enhanced header locale picker as LED-accent badge | alexei.dolgolyov |
| [03d2e6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/03d2e6b) | ci(release): publish .sha256 sidecars alongside release assets | alexei.dolgolyov |
</details>
<!-- markdownlint-enable MD033 -->
+45 -11
View File
@@ -1,16 +1,5 @@
# TODO
## IMPORTANT: Remove WLED naming throughout the app
- [ ] Rename all references to "WLED" in user-facing strings, class names, module names, config keys, file paths, and documentation
- [ ] The app is **LedGrab** — not tied to WLED specifically. WLED is just one of many supported output protocols
- [ ] Audit: i18n keys, page titles, tray labels, installer text, pyproject.toml description, README, CLAUDE.md, context files, API docs
- [ ] Rename `ledgrab` package → decide on new package name (e.g. `ledgrab`)
- [ ] Update import paths, entry points, config references, build scripts, Docker, CI/CD
- [ ] **Migration required** if renaming storage paths or config keys (see data migration policy in CLAUDE.md)
---
## Donation / Open-Source Banner
- [x] Add a persistent but dismissible banner or notification in the dashboard UI informing users that the project is open-source and under active development, and that donations are highly appreciated
@@ -18,3 +7,48 @@
- [x] Remember dismissal in localStorage so it doesn't reappear every session
- [x] Add i18n keys for the banner text (`en.json`, `ru.json`, `zh.json`)
- [ ] Configure `DONATE_URL` and `REPO_URL` constants in `donation.ts` once platform is chosen
## Android Signing Secrets (Gitea)
The CI workflow `build-android.yml` produces a signed release APK **only** when all four secrets below are configured in Gitea → Settings → Secrets. When any one is missing, the "Guard release tag against missing keystore" step hard-fails a `v*` tag build — previously we silently shipped a debug-signed APK labeled as release.
| Secret | Contents |
| --- | --- |
| `ANDROID_KEYSTORE_BASE64` | Output of `base64 -w0 release.jks` — the whole keystore as one line |
| `ANDROID_KEYSTORE_PASSWORD` | Keystore password (the `-storepass` passed to `keytool`) |
| `ANDROID_KEY_ALIAS` | Key alias (e.g. `ledgrab-release`) |
| `ANDROID_KEY_PASSWORD` | Key password (can be the same as keystore password) |
### Generate the keystore (one-time, ~2 min)
```bash
keytool -genkeypair -v \
-storetype JKS \
-keystore release.jks \
-alias ledgrab-release \
-keyalg RSA -keysize 4096 \
-validity 9125 \
-dname "CN=LedGrab, O=Dolgolyov, C=BY"
base64 -w0 release.jks > release.jks.b64 # Linux / Git Bash
# Windows alternative:
# certutil -encode release.jks release.jks.b64
# (strip the -----BEGIN/END CERTIFICATE----- header/footer lines)
```
### Critical — back up `release.jks` outside the repo
- 1Password attachment, encrypted USB stick, or printed hex + password written down somewhere physical.
- Losing the keystore = every existing sideloaded install is permanently unable to upgrade. The only workaround is uninstall-then-reinstall, which wipes user data.
- The `release.jks` file itself must **never** be committed to git. Only the base64 string lives in Gitea secrets.
### Why it matters even without Play Store
Android's package manager refuses to install an upgrade whose signature differs from the currently-installed APK's signature — enforced by the OS, not Play. So once users install a build signed by key X, every future build they can upgrade to must also be signed by key X.
### Current state
- [ ] Generate `release.jks` with `keytool` (above) and back it up
- [ ] Upload the four secrets to Gitea
- [ ] Tag a throwaway `v0.4.1-test` to verify signed release APK is produced (then delete the tag + release)
- [ ] Note: any existing `v0.4.0` debug-signed install cannot upgrade to a release-signed v0.4.1 — users must uninstall first
+1 -1
View File
@@ -40,7 +40,7 @@ android {
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
// sideload updates silently refused to install.
versionCode = ledgrabVersionCode
versionName = "0.4.0"
versionName = "0.4.2"
ndk {
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
+10
View File
@@ -69,6 +69,16 @@ copy_app_files() {
# Clean up source maps and __pycache__
find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true
find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
# Patch the fallback version in the bundled __init__.py. Bundled installs
# strip ledgrab-*.dist-info from site-packages, so importlib.metadata
# falls back to this literal at runtime — and a stale literal is what
# silently shipped v0.4.2 reporting "0.3.0" in the WebUI.
local bundled_init="$APP_DIR/src/ledgrab/__init__.py"
if [ -f "$bundled_init" ] && [ -n "${VERSION_CLEAN:-}" ]; then
sed -i "s/_FALLBACK_VERSION = \"[^\"]*\"/_FALLBACK_VERSION = \"${VERSION_CLEAN}\"/" "$bundled_init"
echo " Patched _FALLBACK_VERSION -> ${VERSION_CLEAN}"
fi
}
# ── Site-packages cleanup ────────────────────────────────────
+17 -3
View File
@@ -178,10 +178,16 @@ echo "[6/9] Downloading Windows dependencies..."
WHEEL_DIR="$BUILD_DIR/win-wheels"
mkdir -p "$WHEEL_DIR"
# Core dependencies (cross-platform, should have win_amd64 wheels)
# Core dependencies (cross-platform, should have win_amd64 wheels).
# KEEP IN SYNC with server/pyproject.toml [project.dependencies] — this
# list duplicates it because cross-build on Linux can't invoke `pip install
# <path>` against pyproject.toml with a Windows target. Missing entries
# ship a broken installer that silently fails under pythonw.exe (no
# traceback visible to the user). Audit after every pyproject.toml edit.
DEPS=(
"fastapi>=0.115.0"
"uvicorn[standard]>=0.32.0"
"cryptography>=42.0.0"
"httpx>=0.27.2"
"mss>=9.0.2"
"numpy>=2.1.3"
@@ -201,6 +207,7 @@ DEPS=(
"aiomqtt>=2.0.0"
"openrgb-python>=0.2.15"
"opencv-python-headless>=4.8.0"
"just-playback>=0.1.7"
)
# Windows-only deps
@@ -291,9 +298,10 @@ compile_and_strip_sources "$SITE_PACKAGES" "python"
echo " Verifying required submodules exist after cleanup..."
for required in \
"numpy/linalg" "numpy/lib" "numpy/matrixlib" "numpy/ma" \
"zeroconf/_services"; do
"zeroconf/_services" \
"cryptography" "cffi" "just_playback"; do
if [ ! -d "$SITE_PACKAGES/$required" ] && [ ! -f "$SITE_PACKAGES/$required.py" ] && [ ! -f "$SITE_PACKAGES/$required.pyc" ]; then
echo " ERROR: $required missing from site-packages — cleanup_site_packages removed something required. Aborting."
echo " ERROR: $required missing from site-packages — either cleanup_site_packages removed something required, or DEPS is out of sync with pyproject.toml. Aborting."
exit 1
fi
done
@@ -328,6 +336,12 @@ cd /d "%~dp0"
set PYTHONPATH=%~dp0app\src
set LEDGRAB_CONFIG_PATH=%~dp0app\config\default_config.yaml
:: Tcl/Tk ship under python\ but Tk's default search path is
:: <python.exe>\..\lib\tcl8.6. Point it at the right location so
:: the screen-overlay feature (tkinter) can start without errors.
set TCL_LIBRARY=%~dp0python\tcl8.6
set TK_LIBRARY=%~dp0python\tk8.6
:: Create data directory if missing
if not exist "%~dp0data" mkdir "%~dp0data"
if not exist "%~dp0logs" mkdir "%~dp0logs"
+17
View File
@@ -196,6 +196,17 @@ New-Item -ItemType Directory -Path (Join-Path $DistDir "logs") -Force | Out-Null
Get-ChildItem -Path $srcDest -Recurse -Filter "*.map" | Remove-Item -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $srcDest -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# Patch the fallback version in the bundled __init__.py so the WebUI always
# reports the release version — the installer strips ledgrab-*.dist-info from
# site-packages (above), so importlib.metadata falls back to this literal.
$bundledInit = Join-Path $srcDest "ledgrab\__init__.py"
if (Test-Path $bundledInit) {
$initContent = Get-Content $bundledInit -Raw
$patched = [regex]::Replace($initContent, '_FALLBACK_VERSION\s*=\s*"[^"]*"', "_FALLBACK_VERSION = `"$VersionClean`"")
Set-Content -Path $bundledInit -Value $patched -NoNewline
Write-Host " Patched _FALLBACK_VERSION -> $VersionClean"
}
# ── Create launcher ────────────────────────────────────────────
Write-Host "[8/8] Creating launcher..."
@@ -208,6 +219,12 @@ cd /d "%~dp0"
set PYTHONPATH=%~dp0app\src
set LEDGRAB_CONFIG_PATH=%~dp0app\config\default_config.yaml
:: Tcl/Tk ship under python\ but Tk's default search path is
:: <python.exe>\..\lib\tcl8.6. Point it at the right location so
:: the screen-overlay feature (tkinter) can start without errors.
set TCL_LIBRARY=%~dp0python\tcl8.6
set TK_LIBRARY=%~dp0python\tk8.6
:: Create data directory if missing
if not exist "%~dp0data" mkdir "%~dp0data"
if not exist "%~dp0logs" mkdir "%~dp0logs"
+8
View File
@@ -98,6 +98,12 @@ Section "!${APPNAME} (required)" SecCore
RMDir /r "$INSTDIR\app"
RMDir /r "$INSTDIR\scripts"
Delete "$INSTDIR\LedGrab.bat"
; Legacy leftovers from the wled_controller-era install. The current
; build does not ship debug.bat, but upgrades from older versions left
; one behind with a stale `-m wled_controller` command that gives a
; misleading ModuleNotFoundError when run. Remove it on upgrade.
Delete "$INSTDIR\debug.bat"
Delete "$INSTDIR\debug.log"
; Copy the entire portable build
File /r "LedGrab\python"
@@ -187,6 +193,8 @@ Section "Uninstall"
RMDir /r "$INSTDIR\app"
RMDir /r "$INSTDIR\scripts"
Delete "$INSTDIR\LedGrab.bat"
Delete "$INSTDIR\debug.bat"
Delete "$INSTDIR\debug.log"
Delete "$INSTDIR\uninstall.exe"
; Remove logs (but keep data/)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ledgrab"
version = "0.4.0"
version = "0.4.2"
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
+7 -3
View File
@@ -2,10 +2,14 @@
from importlib.metadata import version, PackageNotFoundError
# Fallback version — kept in sync with pyproject.toml.
# Fallback version — kept in sync with pyproject.toml. MUST match the
# version declared there on every release. The Windows installer build
# (build/build-dist.ps1) also patches this literal to the resolved build
# version, so any drift here is corrected for bundled distributions.
# Used when the package isn't pip-installed (e.g. embedded via Chaquopy
# on Android, where the source is included directly via source sets).
_FALLBACK_VERSION = "0.3.0"
# on Android, where the source is included directly via source sets, or
# in the Windows bundle where the installed dist-info is stripped).
_FALLBACK_VERSION = "0.4.2"
try:
__version__ = version("ledgrab")
@@ -0,0 +1,78 @@
# Minecraft community adapter
# Requires a server-side mod that sends game state via webhook
# (e.g., GameStateIntegration mod or custom Fabric/Forge mod)
#
# Configure your mod to POST JSON to:
# http://<WLED_IP>:8080/api/v1/game-integrations/<ID>/event
name: minecraft
game: Minecraft
protocol: webhook
mappings:
- source_path: player.health
event: health
min: 0
max: 20
trigger: on_change
- source_path: player.armor
event: armor
min: 0
max: 20
trigger: on_change
- source_path: player.food_level
event: energy
min: 0
max: 20
trigger: on_change
- source_path: player.experience_level
event: speed
min: 0
max: 100
trigger: on_change
- source_path: player.deaths
event: death
trigger: on_increase
min: 0
max: 100
- source_path: stats.kills
event: kill
trigger: on_increase
min: 0
max: 100
auth:
type: header
header: X-Minecraft-Auth
setup_instructions: |
## Minecraft Integration Setup
This adapter requires a server-side mod that sends game state data as JSON.
**Recommended mods:**
- [GameStateIntegration](https://github.com/example/gsi-mod) (Fabric)
- Custom Forge mod using `PlayerTickEvent`
**Expected JSON format:**
```json
{
"player": {
"health": 20.0,
"armor": 10,
"food_level": 18,
"experience_level": 30
},
"stats": {
"kills": 5
}
}
```
Configure the mod to POST to the event endpoint with the auth token
in the `X-Minecraft-Auth` header.
@@ -0,0 +1,99 @@
# Rocket League community adapter
# Uses the SOS (Rocket League Overlay System) plugin
# https://gitlab.com/bakkesplugins/sos/sos-plugin
#
# SOS sends game state via WebSocket, but you can use a bridge
# to forward events as HTTP POST to:
# http://<WLED_IP>:8080/api/v1/game-integrations/<ID>/event
name: rocket_league
game: Rocket League
protocol: webhook
mappings:
- source_path: player.boost
event: energy
min: 0
max: 100
trigger: on_change
- source_path: player.speed
event: speed
min: 0
max: 2300
trigger: on_value
- source_path: match.goals_scored
event: kill
trigger: on_increase
min: 0
max: 20
- source_path: match.goals_conceded
event: death
trigger: on_increase
min: 0
max: 20
- source_path: match.time_remaining
event: objective_progress
min: 0
max: 300
trigger: on_value
- source_path: game.started
event: match_start
trigger: on_change
min: 0
max: 1
- source_path: game.ended
event: match_end
trigger: on_change
min: 0
max: 1
- source_path: team.score_blue
event: team_a
min: 0
max: 10
trigger: on_change
- source_path: team.score_orange
event: team_b
min: 0
max: 10
trigger: on_change
setup_instructions: |
## Rocket League Integration Setup
This adapter works with the SOS (Rocket League Overlay System) plugin.
**Setup:**
1. Install BakkesMod: https://bakkesmod.com
2. Install the SOS plugin from the BakkesMod plugin manager
3. Use a WebSocket-to-HTTP bridge to forward SOS events
**Bridge tool:**
A small script that connects to SOS WebSocket (ws://localhost:49122)
and forwards events as HTTP POST to the WLED event endpoint.
**Expected JSON format:**
```json
{
"player": {
"boost": 75,
"speed": 1500
},
"match": {
"goals_scored": 2,
"goals_conceded": 1,
"time_remaining": 180
},
"team": {
"score_blue": 2,
"score_orange": 1
}
}
```
@@ -0,0 +1,85 @@
# Valorant community adapter
# Uses Overwolf/Insights API or third-party overlay tool
# that exposes game state via webhook
#
# Configure your overlay to POST JSON to:
# http://<WLED_IP>:8080/api/v1/game-integrations/<ID>/event
name: valorant
game: Valorant
protocol: webhook
mappings:
- source_path: player.health
event: health
min: 0
max: 100
trigger: on_change
- source_path: player.shield
event: shield
min: 0
max: 50
trigger: on_change
- source_path: player.money
event: gold
min: 0
max: 9000
trigger: on_change
- source_path: match.kills
event: kill
trigger: on_increase
min: 0
max: 50
- source_path: match.deaths
event: death
trigger: on_increase
min: 0
max: 50
- source_path: match.round_phase
event: round_start
trigger: on_change
min: 0
max: 1
- source_path: match.spike_planted
event: objective_captured
trigger: on_change
min: 0
max: 1
auth:
type: header
header: X-Valorant-Auth
setup_instructions: |
## Valorant Integration Setup
Valorant does not have a native Game State Integration API.
You need a third-party tool to capture and forward game data.
**Options:**
- Overwolf with a game events plugin
- Insights.gg capture API
- Custom screen-reading overlay
**Expected JSON format:**
```json
{
"player": {
"health": 100,
"shield": 50,
"money": 3900
},
"match": {
"kills": 12,
"deaths": 5,
"round_phase": 1,
"spike_planted": 0
}
}
```
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+55
View File
@@ -100,6 +100,61 @@ h2 {
background: var(--bg-secondary);
}
/* ── Header locale picker (post IconSelect enhancement) ────────────────────
The hidden <select.header-locale> is enhanced into a trigger button at
runtime. Inside the toolbar, re-skin it to match .header-btn so it reads
as a peer of the icon buttons, with the 2-letter code rendered as a small
LED-style accent badge in Orbitron — same display font as the brand mark. */
.header-toolbar .icon-select-trigger {
width: auto;
gap: 6px;
padding: 3px 6px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--text-secondary);
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
transition: color 0.2s, background 0.2s;
}
.header-toolbar .icon-select-trigger:hover {
color: var(--text-color);
background: var(--bg-secondary);
border-color: transparent;
}
.header-toolbar .icon-select-trigger-icon {
font-family: 'Orbitron', sans-serif;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--primary-color);
padding: 3px 5px;
border-radius: 4px;
background: color-mix(in srgb, var(--primary-color) 14%, transparent);
line-height: 1;
}
.header-toolbar .icon-select-trigger-icon > span {
font-weight: inherit;
}
.header-toolbar .icon-select-trigger-label {
flex: 0 1 auto;
font-weight: 500;
font-size: 0.72rem;
letter-spacing: 0.01em;
margin: 0;
}
.header-toolbar .icon-select-trigger-arrow {
font-size: 0.6rem;
opacity: 0.55;
margin-left: 1px;
}
#server-version {
font-family: 'Orbitron', sans-serif;
font-size: 0.65rem;
+10
View File
@@ -140,6 +140,16 @@
max-width: 48px;
}
/* Collapse the enhanced locale trigger to just the 2-letter badge on
narrow screens, matching the compact footprint of the icon buttons. */
.header-toolbar .icon-select-trigger-label {
display: none;
}
.header-toolbar .icon-select-trigger {
gap: 4px;
padding: 3px 5px;
}
h1 {
font-size: 1.1rem;
}
+65 -52
View File
@@ -274,6 +274,71 @@
display: block;
}
/* Key Colors test view — frame with region overlays */
.css-test-kc-wrap {
position: relative;
width: 100%;
border-radius: 4px;
overflow: hidden;
background: #000;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
}
.css-test-kc-canvas {
display: block;
width: 100%;
height: auto;
max-height: 70vh;
object-fit: contain;
}
.css-test-kc-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px 12px;
padding: 8px 0 2px;
font-size: 0.8rem;
color: var(--text-color-muted, #aaa);
}
.css-test-kc-swatch {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 6px 2px 2px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border-color, #333);
border-radius: 999px;
font-size: 0.78rem;
color: var(--text-color, #e0e0e0);
}
.css-test-kc-swatch-chip {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.25);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
}
.css-test-kc-swatch-hex {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.72rem;
opacity: 0.65;
}
.css-test-kc-mode {
opacity: 0.7;
font-variant: small-caps;
letter-spacing: 0.04em;
}
.css-test-status {
text-align: center;
padding: 8px 0;
@@ -1246,58 +1311,6 @@
background: rgba(255, 255, 255, 0.3);
}
.lightbox-refresh-btn {
position: absolute;
top: 16px;
right: 64px;
background: rgba(255, 255, 255, 0.15);
border: none;
color: white;
font-size: 1.2rem;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
z-index: 1;
}
.lightbox-refresh-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.lightbox-refresh-btn.active {
background: var(--primary-color);
}
.lightbox-fps-select {
position: absolute;
top: 16px;
right: 116px;
background: rgba(0, 0, 0, 0.65);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 6px;
padding: 4px 6px;
font-size: 0.8rem;
cursor: pointer;
z-index: 1;
appearance: none;
-webkit-appearance: none;
text-align: center;
}
.lightbox-fps-select:hover {
background: rgba(255, 255, 255, 0.15);
}
.lightbox-fps-select:focus {
outline: 1px solid var(--primary-color);
}
.lightbox-stats {
position: absolute;
bottom: 8px;
+1 -4
View File
@@ -22,7 +22,7 @@ import {
toggleHint, lockBody, unlockBody, closeLightbox,
showToast, showUndoToast, showConfirm, closeConfirmModal,
openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner,
setFieldError, clearFieldError, setupBlurValidation, initLightbox,
setFieldError, clearFieldError, setupBlurValidation,
} from './core/ui.ts';
// Layer 3: displays, tutorials
@@ -754,9 +754,6 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize command palette
initCommandPalette();
// Enhance lightbox FPS <select> with IconSelect
initLightbox();
// Setup form handler
const addDeviceForm = queryEl('add-device-form');
if (addDeviceForm) addDeviceForm.addEventListener('submit', handleAddDevice);
-24
View File
@@ -5,30 +5,6 @@
import { confirmResolve, setConfirmResolve } from './state.ts';
import { API_BASE, getHeaders } from './api.ts';
import { t } from './i18n.ts';
import { IconSelect } from './icon-select.ts';
let _lightboxFpsIconSelect: IconSelect | null = null;
/** Enhance the lightbox FPS <select> with an IconSelect. Idempotent. */
export function initLightbox(): void {
if (_lightboxFpsIconSelect) return;
const sel = document.getElementById('lightbox-fps-select') as HTMLSelectElement | null;
if (!sel) return;
_lightboxFpsIconSelect = new IconSelect({
target: sel,
items: [
{ value: '1', icon: '<span style="font-weight:700">1</span>', label: '1 fps' },
{ value: '2', icon: '<span style="font-weight:700">2</span>', label: '2 fps' },
{ value: '3', icon: '<span style="font-weight:700">3</span>', label: '3 fps' },
{ value: '5', icon: '<span style="font-weight:700">5</span>', label: '5 fps' },
],
columns: 2,
onChange: (val: string) => {
const fn = (window as any).onLightboxFpsChange;
if (typeof fn === 'function') fn(val);
},
});
}
/** Returns true on touch devices where auto-focus would pop up the virtual keyboard */
export function isTouchDevice() {
@@ -7,7 +7,7 @@ import { fetchWithAuth, escapeHtml } from '../../core/api.ts';
import { logError } from '../../core/log.ts';
import { colorStripSourcesCache } from '../../core/state.ts';
import { t } from '../../core/i18n.ts';
import { showToast, openLightbox, closeLightbox } from '../../core/ui.ts';
import { showToast } from '../../core/ui.ts';
import { createFpsSparkline } from '../../core/chart-utils.ts';
import {
getColorStripIcon,
@@ -97,6 +97,10 @@ let _cssTestTransientConfig: any = null;
const _CSS_TEST_LED_KEY = 'css_test_led_count';
const _CSS_TEST_FPS_KEY = 'css_test_fps';
const _CSS_TEST_KC_FPS_KEY = 'css_test_kc_fps';
const _CSS_TEST_KC_FPS_DEFAULT = 5;
const _CSS_TEST_KC_FPS_MIN = 1;
const _CSS_TEST_KC_FPS_MAX = 30;
let _cssTestWs: WebSocket | null = null;
let _cssTestRaf: number | null = null;
let _cssTestLatestRgb: Uint8Array | null = null;
@@ -109,6 +113,7 @@ let _cssTestNotificationIds: string[] = []; // notification source IDs to fire (
let _cssTestCSPTMode: boolean = false; // true when testing a CSPT template
let _cssTestCSPTId: string | null = null; // CSPT template ID when in CSPT mode
let _cssTestIsApiInput: boolean = false;
let _cssTestIsKeyColors: boolean = false;
let _cssTestFpsTimestamps: number[] = []; // raw timestamps for current-second FPS calculation
let _cssTestFpsActualHistory: number[] = []; // rolling FPS samples for sparkline
let _cssTestFpsChart: any = null;
@@ -125,6 +130,11 @@ function _getCssTestFps() {
return (stored >= 1 && stored <= 60) ? stored : 20;
}
function _getKCTestFps() {
const stored = parseInt(localStorage.getItem(_CSS_TEST_KC_FPS_KEY) ?? '', 10);
return (stored >= _CSS_TEST_KC_FPS_MIN && stored <= _CSS_TEST_KC_FPS_MAX) ? stored : _CSS_TEST_KC_FPS_DEFAULT;
}
function _populateCssTestSourceSelector(preselectId: any) {
const sources = (colorStripSourcesCache.data || []) as any[];
const nonProcessed = sources.filter(s => s.source_type !== 'processed');
@@ -162,81 +172,139 @@ export function testColorStrip(sourceId: string) {
}
let _kcTestWs: WebSocket | null = null;
const _kcTestCanvas = document.createElement('canvas');
const BORDER_COLORS = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96e6a1', '#dda0dd', '#f9ca24', '#ff9ff3', '#54a0ff'];
function _testKeyColorsSource(sourceId: string) {
// Show lightbox with spinner
const lightbox = document.getElementById('image-lightbox')!;
const spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement | null;
const content = lightbox.querySelector('.lightbox-content') as HTMLElement | null;
if (content) content.style.width = '90vw'; // Fill viewport for KC preview
const img = document.getElementById('lightbox-image') as HTMLImageElement;
img.src = '';
img.style.display = 'none'; // Hide until first frame arrives
if (spinner) spinner.style.display = '';
document.getElementById('lightbox-stats')!.style.display = 'none';
lightbox.classList.add('active');
_cssTestCSPTMode = false;
_cssTestCSPTId = null;
_cssTestIsApiInput = false;
_cssTestIsKeyColors = true;
_cssTestSourceId = sourceId;
// Close any previous WS
// Close any previous sessions
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestLayerData = null;
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
if (!modal) return;
modal.style.display = 'flex';
modal.onclick = (e) => { if (e.target === modal) closeTestCssSourceModal(); };
// Show only the KC view; hide all others
(document.getElementById('css-test-strip-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-rect-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-layers-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-kc-view') as HTMLElement).style.display = '';
(document.getElementById('css-test-fps-chart-group') as HTMLElement).style.display = 'none';
// CSPT input selector is not relevant for KC
const csptGroup = document.getElementById('css-test-cspt-input-group') as HTMLElement | null;
if (csptGroup) csptGroup.style.display = 'none';
// LED count doesn't apply to KC — hide LED group; keep FPS input visible
(document.getElementById('css-test-led-fps-group') as HTMLElement).style.display = '';
(document.getElementById('css-test-led-group') as HTMLElement).style.display = 'none';
const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null;
if (fpsInput) {
fpsInput.min = String(_CSS_TEST_KC_FPS_MIN);
fpsInput.max = String(_CSS_TEST_KC_FPS_MAX);
fpsInput.value = String(_getKCTestFps());
fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
}
// Widen modal to give the frame room to breathe
const modalContent = modal.querySelector('.modal-content') as HTMLElement | null;
if (modalContent) modalContent.style.maxWidth = '900px';
// Clear any stale KC state
const canvas = document.getElementById('css-test-kc-canvas') as HTMLCanvasElement | null;
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
}
const metaEl = document.getElementById('css-test-kc-meta') as HTMLElement | null;
if (metaEl) metaEl.innerHTML = '';
const statusEl = document.getElementById('css-test-status') as HTMLElement;
statusEl.textContent = t('color_strip.test.connecting');
statusEl.style.display = '';
_kcTestConnect(sourceId, _getKCTestFps());
}
function _kcTestConnect(sourceId: string, fps: number) {
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
// Build WS URL
const gen = ++_cssTestGeneration;
const loc = window.location;
const wsProto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProto}//${loc.host}/api/v1/color-strip-sources/${sourceId}/key-colors/test/ws?fps=5&preview_width=960`;
const clamped = Math.max(_CSS_TEST_KC_FPS_MIN, Math.min(_CSS_TEST_KC_FPS_MAX, fps));
const wsUrl = `${wsProto}//${loc.host}/api/v1/color-strip-sources/${sourceId}/key-colors/test/ws?fps=${clamped}&preview_width=960`;
openAuthedWs(wsUrl).then((ws) => {
if (gen !== _cssTestGeneration) { ws.close(); return; }
_kcTestWs = ws;
ws.onmessage = (ev) => {
if (gen !== _cssTestGeneration) return;
try {
const data = JSON.parse(ev.data);
if (data.type === 'frame') {
_renderKCTestFrame(data);
const statusEl = document.getElementById('css-test-status') as HTMLElement | null;
if (statusEl) statusEl.style.display = 'none';
}
} catch (err) { logError('color-strips.test.kcWs.message', err); }
};
ws.onerror = () => {
showToast('Key Colors test connection failed', 'error');
closeLightbox();
if (gen !== _cssTestGeneration) return;
const statusEl = document.getElementById('css-test-status') as HTMLElement;
statusEl.textContent = t('color_strip.test.error');
statusEl.style.display = '';
};
ws.onclose = () => {
ws.onclose = (ev) => {
if (gen !== _cssTestGeneration) return;
_kcTestWs = null;
};
// Stop WS when lightbox closes
const origClose = (window as any).closeLightbox;
lightbox.onclick = (e) => {
if ((e.target as HTMLElement).closest('.lightbox-content')) return;
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
closeLightbox();
if (ev.reason) {
const statusEl = document.getElementById('css-test-status') as HTMLElement;
statusEl.textContent = ev.reason;
statusEl.style.display = '';
}
};
}).catch(() => {
showToast('Key Colors test connection failed', 'error');
closeLightbox();
if (gen !== _cssTestGeneration) return;
const statusEl = document.getElementById('css-test-status') as HTMLElement;
statusEl.textContent = t('color_strip.test.error');
statusEl.style.display = '';
});
}
function _renderKCTestFrame(data: any) {
const rects = data.rectangles || [];
const mode = data.interpolation_mode || 'average';
const canvas = document.getElementById('css-test-kc-canvas') as HTMLCanvasElement | null;
const metaEl = document.getElementById('css-test-kc-meta') as HTMLElement | null;
if (!canvas) return;
// Draw frame + rectangles onto offscreen canvas
const tmpImg = new Image();
tmpImg.onload = () => {
_kcTestCanvas.width = tmpImg.naturalWidth;
_kcTestCanvas.height = tmpImg.naturalHeight;
const ctx = _kcTestCanvas.getContext('2d')!;
canvas.width = tmpImg.naturalWidth;
canvas.height = tmpImg.naturalHeight;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(tmpImg, 0, 0);
rects.forEach((r: any, i: number) => {
const x = r.x * _kcTestCanvas.width;
const y = r.y * _kcTestCanvas.height;
const w = r.width * _kcTestCanvas.width;
const h = r.height * _kcTestCanvas.height;
const x = r.x * canvas.width;
const y = r.y * canvas.height;
const w = r.width * canvas.width;
const h = r.height * canvas.height;
const borderColor = BORDER_COLORS[i % BORDER_COLORS.length];
ctx.fillStyle = r.color.hex + '33';
@@ -258,36 +326,19 @@ function _renderKCTestFrame(data: any) {
ctx.lineWidth = 1;
ctx.strokeRect(x + w - 24, y + 2, 22, 22);
});
// Update lightbox image directly (use data URL for full-size display)
const lbImg = document.getElementById('lightbox-image') as HTMLImageElement;
if (lbImg) {
lbImg.src = _kcTestCanvas.toDataURL('image/jpeg', 0.9);
lbImg.style.display = '';
lbImg.style.maxWidth = '100%';
lbImg.style.width = '100%';
}
// Hide spinner after first frame
const spinner = document.querySelector('#image-lightbox .lightbox-spinner') as HTMLElement | null;
if (spinner) spinner.style.display = 'none';
// Update swatches
const statsEl = document.getElementById('lightbox-stats')!;
const swatches = rects.map((r: any) =>
`<div style="display:inline-flex;align-items:center;gap:6px;margin:4px 8px;">
<span style="display:inline-block;width:20px;height:20px;background:${r.color.hex};border:1px solid #888;border-radius:3px;"></span>
<span>${escapeHtml(r.name)}</span>
<small style="opacity:0.6;">${r.color.hex}</small>
</div>`
).join('');
statsEl.innerHTML = `
<div style="display:flex;flex-wrap:wrap;justify-content:center;">${swatches}</div>
<div style="margin-top:4px;opacity:0.6;text-align:center;">Mode: ${mode} | ${rects.length} region${rects.length !== 1 ? 's' : ''}</div>
`;
statsEl.style.display = '';
};
tmpImg.src = data.image;
if (metaEl) {
const swatches = rects.map((r: any) =>
`<span class="css-test-kc-swatch">
<span class="css-test-kc-swatch-chip" style="background:${r.color.hex}"></span>
<span>${escapeHtml(r.name)}</span>
<span class="css-test-kc-swatch-hex">${r.color.hex}</span>
</span>`
).join('');
metaEl.innerHTML = `${swatches}<span class="css-test-kc-mode">${mode} · ${rects.length}&nbsp;region${rects.length !== 1 ? 's' : ''}</span>`;
}
}
export async function testCSPT(templateId: string) {
@@ -310,10 +361,12 @@ export async function testCSPT(templateId: string) {
function _openTestModal(sourceId: string) {
// Clean up any previous session fully
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestIsComposite = false;
_cssTestIsKeyColors = false;
_cssTestLayerData = null;
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
@@ -326,6 +379,8 @@ function _openTestModal(sourceId: string) {
(document.getElementById('css-test-strip-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-rect-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-layers-view') as HTMLElement).style.display = 'none';
const kcView = document.getElementById('css-test-kc-view') as HTMLElement | null;
if (kcView) kcView.style.display = 'none';
// Clear all test canvases to prevent stale frames from previous sessions
modal.querySelectorAll('canvas').forEach(c => {
const ctx = c.getContext('2d');
@@ -363,8 +418,12 @@ function _openTestModal(sourceId: string) {
const fpsVal = _getCssTestFps();
const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null;
fpsInput!.value = fpsVal as any;
fpsInput!.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
if (fpsInput) {
fpsInput.min = '1';
fpsInput.max = '60';
fpsInput.value = String(fpsVal);
fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
}
_cssTestConnect(sourceId, ledCount, fpsVal);
}
@@ -621,6 +680,18 @@ function _cssTestUpdateBrightness(values: any) {
export function applyCssTestSettings() {
if (!_cssTestSourceId) return;
// Key Colors test: FPS only — different range and storage key
if (_cssTestIsKeyColors) {
const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null;
let fps = parseInt(fpsInput?.value ?? '', 10);
if (isNaN(fps) || fps < _CSS_TEST_KC_FPS_MIN) fps = _CSS_TEST_KC_FPS_MIN;
if (fps > _CSS_TEST_KC_FPS_MAX) fps = _CSS_TEST_KC_FPS_MAX;
if (fpsInput) fpsInput.value = String(fps);
localStorage.setItem(_CSS_TEST_KC_FPS_KEY, String(fps));
_kcTestConnect(_cssTestSourceId, fps);
return;
}
const ledInput = document.getElementById('css-test-led-input') as HTMLInputElement | null;
let leds = parseInt(ledInput?.value ?? '', 10);
if (isNaN(leds) || leds < 1) leds = 1;
@@ -1060,11 +1131,13 @@ function _cssTestStopFpsSampling() {
export function closeTestCssSourceModal() {
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestSourceId = null;
_cssTestIsComposite = false;
_cssTestIsKeyColors = false;
_cssTestLayerData = null;
_cssTestNotificationIds = [];
_cssTestIsApiInput = false;
@@ -44,6 +44,14 @@
<canvas id="css-test-layers-axis" class="css-test-strip-axis"></canvas>
</div>
<!-- Key Colors view (frame + region overlays) -->
<div id="css-test-kc-view" style="display:none">
<div class="css-test-kc-wrap">
<canvas id="css-test-kc-canvas" class="css-test-kc-canvas"></canvas>
</div>
<div id="css-test-kc-meta" class="css-test-kc-meta"></div>
</div>
<!-- CSPT test: input source selector (hidden by default) -->
<div id="css-test-cspt-input-group" style="display:none" class="css-test-led-control">
<label for="css-test-cspt-input-select" data-i18n="color_strip.processed.input">Source:</label>
@@ -1,13 +1,6 @@
<!-- Image Lightbox -->
<div id="image-lightbox" class="lightbox" onclick="closeLightbox(event)">
<button class="lightbox-close" onclick="closeLightbox()" title="Close">&#x2715;</button>
<button id="lightbox-auto-refresh" class="lightbox-refresh-btn" onclick="toggleKCTestAutoRefresh()" title="Stream live" style="display:none">&#x25B6;</button>
<select id="lightbox-fps-select" class="lightbox-fps-select" style="display:none" title="Frames per second">
<option value="1">1 fps</option>
<option value="2">2 fps</option>
<option value="3" selected>3 fps</option>
<option value="5">5 fps</option>
</select>
<div class="lightbox-content">
<img id="lightbox-image" src="" alt="Full size preview">
<div id="lightbox-stats" class="lightbox-stats" style="display: none;"></div>