Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c44bb38c43 | |||
| be2d5e1670 | |||
| 5db6eddcf8 | |||
| a8a4296a56 | |||
| 9ce1dc33bf | |||
| 03d2e6b1f2 | |||
| c2c9af3c60 | |||
| 4f7794ccd4 | |||
| a0d63a3663 | |||
| 35b75a2ed8 | |||
| 4ed099d564 |
@@ -23,6 +23,12 @@ jobs:
|
|||||||
ANDROID_SDK_PLATFORM: 'android-34'
|
ANDROID_SDK_PLATFORM: 'android-34'
|
||||||
ANDROID_BUILD_TOOLS: '34.0.0'
|
ANDROID_BUILD_TOOLS: '34.0.0'
|
||||||
ANDROID_NDK_VERSION: '26.1.10909125'
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -108,15 +114,23 @@ jobs:
|
|||||||
|
|
||||||
- name: Decode signing keystore
|
- name: Decode signing keystore
|
||||||
id: keystore
|
id: keystore
|
||||||
if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }}
|
if: env.ANDROID_KEYSTORE_BASE64 != ''
|
||||||
env:
|
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
mkdir -p android/keystore
|
mkdir -p android/keystore
|
||||||
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/keystore/release.jks
|
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/keystore/release.jks
|
||||||
echo "path=$(pwd)/android/keystore/release.jks" >> "$GITHUB_OUTPUT"
|
echo "path=$(pwd)/android/keystore/release.jks" >> "$GITHUB_OUTPUT"
|
||||||
echo "present=true" >> "$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
|
- name: Build APK
|
||||||
working-directory: android
|
working-directory: android
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -90,9 +90,9 @@ jobs:
|
|||||||
|
|
||||||
### First-time setup
|
### First-time setup
|
||||||
|
|
||||||
1. Change the default API key in config/default_config.yaml
|
1. Change the default API key in `config/default_config.yaml`.
|
||||||
2. Open http://localhost:8080 and discover your WLED devices
|
2. Open http://localhost:8080 and add your LED devices.
|
||||||
3. See INSTALLATION.md for detailed configuration
|
3. See `INSTALLATION.md` for detailed configuration.
|
||||||
''').strip())
|
''').strip())
|
||||||
|
|
||||||
print(json.dumps('\n\n'.join(sections)))
|
print(json.dumps('\n\n'.join(sections)))
|
||||||
@@ -191,11 +191,21 @@ jobs:
|
|||||||
echo "Uploaded: $NAME"
|
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)
|
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)
|
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 ──────────────────────────────────────────
|
# ── Linux tarball ──────────────────────────────────────────
|
||||||
build-linux:
|
build-linux:
|
||||||
@@ -242,25 +252,33 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
||||||
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||||
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
|
upload_asset() {
|
||||||
|
local FILE="$1"
|
||||||
|
local NAME
|
||||||
|
NAME=$(basename "$FILE")
|
||||||
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
|
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
-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)
|
| 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
|
if [ -n "$EXISTING_ID" ]; then
|
||||||
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
|
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
|
||||||
-H "Authorization: token $GITEA_TOKEN"
|
-H "Authorization: token $GITEA_TOKEN"
|
||||||
echo "Replaced existing asset: $TAR_NAME"
|
echo "Replaced existing asset: $NAME"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$TAR_NAME" \
|
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary "@$TAR_FILE"
|
--data-binary "@$FILE"
|
||||||
echo "Uploaded: $TAR_NAME"
|
echo "Uploaded: $NAME"
|
||||||
|
}
|
||||||
|
|
||||||
|
TAR_FILE=$(ls build/LedGrab-*.tar.gz | head -1)
|
||||||
|
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
|
||||||
|
|
||||||
# ── Docker image ───────────────────────────────────────────
|
# ── Docker image ───────────────────────────────────────────
|
||||||
build-docker:
|
build-docker:
|
||||||
|
|||||||
+6
-2
@@ -62,8 +62,12 @@ htmlcov/
|
|||||||
logs/
|
logs/
|
||||||
*.log.*
|
*.log.*
|
||||||
|
|
||||||
# Runtime data
|
# Runtime data — anchor to repo root so nested package data dirs
|
||||||
data/
|
# (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
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.json.bak
|
*.json.bak
|
||||||
|
|||||||
+15
-88
@@ -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
|
### 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))
|
||||||
### 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 1–4) ([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))
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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))
|
#### Refactoring
|
||||||
- Add Android APK row to release downloads table ([2477e00](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2477e00))
|
- 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))
|
||||||
- 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))
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- markdownlint-disable MD033 -->
|
|
||||||
<details>
|
<details>
|
||||||
<summary>All Commits</summary>
|
<summary>All Commits</summary>
|
||||||
|
|
||||||
| Hash | Message | Author |
|
| 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 |
|
| [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 |
|
||||||
| [5d6310f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5d6310f) | fix(android): make wheels find-links URL work on Linux CI | alexei.dolgolyov |
|
| [5db6edd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5db6edd) | fix(release): ship prebuilt assets and bump fallback version | 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 |
|
| [9ce1dc3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ce1dc3) | feat(ui): restyle enhanced header locale picker as LED-accent badge | 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 |
|
| [03d2e6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/03d2e6b) | ci(release): publish .sha256 sidecars alongside release assets | 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 |
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
<!-- markdownlint-enable MD033 -->
|
|
||||||
|
|||||||
+45
-11
@@ -1,16 +1,5 @@
|
|||||||
# TODO
|
# 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
|
## 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
|
- [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] 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`)
|
- [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
|
- [ ] 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
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ android {
|
|||||||
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
|
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
|
||||||
// sideload updates silently refused to install.
|
// sideload updates silently refused to install.
|
||||||
versionCode = ledgrabVersionCode
|
versionCode = ledgrabVersionCode
|
||||||
versionName = "0.4.0"
|
versionName = "0.4.2"
|
||||||
|
|
||||||
ndk {
|
ndk {
|
||||||
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
|
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ copy_app_files() {
|
|||||||
# Clean up source maps and __pycache__
|
# Clean up source maps and __pycache__
|
||||||
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
|
||||||
|
|
||||||
|
# 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 ────────────────────────────────────
|
# ── Site-packages cleanup ────────────────────────────────────
|
||||||
|
|||||||
@@ -178,10 +178,16 @@ echo "[6/9] Downloading Windows dependencies..."
|
|||||||
WHEEL_DIR="$BUILD_DIR/win-wheels"
|
WHEEL_DIR="$BUILD_DIR/win-wheels"
|
||||||
mkdir -p "$WHEEL_DIR"
|
mkdir -p "$WHEEL_DIR"
|
||||||
|
|
||||||
# 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=(
|
DEPS=(
|
||||||
"fastapi>=0.115.0"
|
"fastapi>=0.115.0"
|
||||||
"uvicorn[standard]>=0.32.0"
|
"uvicorn[standard]>=0.32.0"
|
||||||
|
"cryptography>=42.0.0"
|
||||||
"httpx>=0.27.2"
|
"httpx>=0.27.2"
|
||||||
"mss>=9.0.2"
|
"mss>=9.0.2"
|
||||||
"numpy>=2.1.3"
|
"numpy>=2.1.3"
|
||||||
@@ -201,6 +207,7 @@ DEPS=(
|
|||||||
"aiomqtt>=2.0.0"
|
"aiomqtt>=2.0.0"
|
||||||
"openrgb-python>=0.2.15"
|
"openrgb-python>=0.2.15"
|
||||||
"opencv-python-headless>=4.8.0"
|
"opencv-python-headless>=4.8.0"
|
||||||
|
"just-playback>=0.1.7"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Windows-only deps
|
# Windows-only deps
|
||||||
@@ -291,9 +298,10 @@ compile_and_strip_sources "$SITE_PACKAGES" "python"
|
|||||||
echo " Verifying required submodules exist after cleanup..."
|
echo " Verifying required submodules exist after cleanup..."
|
||||||
for required in \
|
for required in \
|
||||||
"numpy/linalg" "numpy/lib" "numpy/matrixlib" "numpy/ma" \
|
"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
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -328,6 +336,12 @@ cd /d "%~dp0"
|
|||||||
set PYTHONPATH=%~dp0app\src
|
set PYTHONPATH=%~dp0app\src
|
||||||
set LEDGRAB_CONFIG_PATH=%~dp0app\config\default_config.yaml
|
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
|
:: Create data directory if missing
|
||||||
if not exist "%~dp0data" mkdir "%~dp0data"
|
if not exist "%~dp0data" mkdir "%~dp0data"
|
||||||
if not exist "%~dp0logs" mkdir "%~dp0logs"
|
if not exist "%~dp0logs" mkdir "%~dp0logs"
|
||||||
|
|||||||
@@ -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 -Filter "*.map" | Remove-Item -Force -ErrorAction SilentlyContinue
|
||||||
Get-ChildItem -Path $srcDest -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -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 ────────────────────────────────────────────
|
# ── Create launcher ────────────────────────────────────────────
|
||||||
|
|
||||||
Write-Host "[8/8] Creating launcher..."
|
Write-Host "[8/8] Creating launcher..."
|
||||||
@@ -208,6 +219,12 @@ cd /d "%~dp0"
|
|||||||
set PYTHONPATH=%~dp0app\src
|
set PYTHONPATH=%~dp0app\src
|
||||||
set LEDGRAB_CONFIG_PATH=%~dp0app\config\default_config.yaml
|
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
|
:: Create data directory if missing
|
||||||
if not exist "%~dp0data" mkdir "%~dp0data"
|
if not exist "%~dp0data" mkdir "%~dp0data"
|
||||||
if not exist "%~dp0logs" mkdir "%~dp0logs"
|
if not exist "%~dp0logs" mkdir "%~dp0logs"
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ Section "!${APPNAME} (required)" SecCore
|
|||||||
RMDir /r "$INSTDIR\app"
|
RMDir /r "$INSTDIR\app"
|
||||||
RMDir /r "$INSTDIR\scripts"
|
RMDir /r "$INSTDIR\scripts"
|
||||||
Delete "$INSTDIR\LedGrab.bat"
|
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
|
; Copy the entire portable build
|
||||||
File /r "LedGrab\python"
|
File /r "LedGrab\python"
|
||||||
@@ -187,6 +193,8 @@ Section "Uninstall"
|
|||||||
RMDir /r "$INSTDIR\app"
|
RMDir /r "$INSTDIR\app"
|
||||||
RMDir /r "$INSTDIR\scripts"
|
RMDir /r "$INSTDIR\scripts"
|
||||||
Delete "$INSTDIR\LedGrab.bat"
|
Delete "$INSTDIR\LedGrab.bat"
|
||||||
|
Delete "$INSTDIR\debug.bat"
|
||||||
|
Delete "$INSTDIR\debug.log"
|
||||||
Delete "$INSTDIR\uninstall.exe"
|
Delete "$INSTDIR\uninstall.exe"
|
||||||
|
|
||||||
; Remove logs (but keep data/)
|
; Remove logs (but keep data/)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ledgrab"
|
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"
|
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
from importlib.metadata import version, PackageNotFoundError
|
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
|
# Used when the package isn't pip-installed (e.g. embedded via Chaquopy
|
||||||
# on Android, where the source is included directly via source sets).
|
# on Android, where the source is included directly via source sets, or
|
||||||
_FALLBACK_VERSION = "0.3.0"
|
# in the Windows bundle where the installed dist-info is stripped).
|
||||||
|
_FALLBACK_VERSION = "0.4.2"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
__version__ = version("ledgrab")
|
__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.
@@ -100,6 +100,61 @@ h2 {
|
|||||||
background: var(--bg-secondary);
|
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 {
|
#server-version {
|
||||||
font-family: 'Orbitron', sans-serif;
|
font-family: 'Orbitron', sans-serif;
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
|
|||||||
@@ -140,6 +140,16 @@
|
|||||||
max-width: 48px;
|
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 {
|
h1 {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,6 +274,71 @@
|
|||||||
display: block;
|
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 {
|
.css-test-status {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
@@ -1246,58 +1311,6 @@
|
|||||||
background: rgba(255, 255, 255, 0.3);
|
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 {
|
.lightbox-stats {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
toggleHint, lockBody, unlockBody, closeLightbox,
|
toggleHint, lockBody, unlockBody, closeLightbox,
|
||||||
showToast, showUndoToast, showConfirm, closeConfirmModal,
|
showToast, showUndoToast, showConfirm, closeConfirmModal,
|
||||||
openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner,
|
openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner,
|
||||||
setFieldError, clearFieldError, setupBlurValidation, initLightbox,
|
setFieldError, clearFieldError, setupBlurValidation,
|
||||||
} from './core/ui.ts';
|
} from './core/ui.ts';
|
||||||
|
|
||||||
// Layer 3: displays, tutorials
|
// Layer 3: displays, tutorials
|
||||||
@@ -754,9 +754,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// Initialize command palette
|
// Initialize command palette
|
||||||
initCommandPalette();
|
initCommandPalette();
|
||||||
|
|
||||||
// Enhance lightbox FPS <select> with IconSelect
|
|
||||||
initLightbox();
|
|
||||||
|
|
||||||
// Setup form handler
|
// Setup form handler
|
||||||
const addDeviceForm = queryEl('add-device-form');
|
const addDeviceForm = queryEl('add-device-form');
|
||||||
if (addDeviceForm) addDeviceForm.addEventListener('submit', handleAddDevice);
|
if (addDeviceForm) addDeviceForm.addEventListener('submit', handleAddDevice);
|
||||||
|
|||||||
@@ -5,30 +5,6 @@
|
|||||||
import { confirmResolve, setConfirmResolve } from './state.ts';
|
import { confirmResolve, setConfirmResolve } from './state.ts';
|
||||||
import { API_BASE, getHeaders } from './api.ts';
|
import { API_BASE, getHeaders } from './api.ts';
|
||||||
import { t } from './i18n.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 */
|
/** Returns true on touch devices where auto-focus would pop up the virtual keyboard */
|
||||||
export function isTouchDevice() {
|
export function isTouchDevice() {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { fetchWithAuth, escapeHtml } from '../../core/api.ts';
|
|||||||
import { logError } from '../../core/log.ts';
|
import { logError } from '../../core/log.ts';
|
||||||
import { colorStripSourcesCache } from '../../core/state.ts';
|
import { colorStripSourcesCache } from '../../core/state.ts';
|
||||||
import { t } from '../../core/i18n.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 { createFpsSparkline } from '../../core/chart-utils.ts';
|
||||||
import {
|
import {
|
||||||
getColorStripIcon,
|
getColorStripIcon,
|
||||||
@@ -97,6 +97,10 @@ let _cssTestTransientConfig: any = null;
|
|||||||
|
|
||||||
const _CSS_TEST_LED_KEY = 'css_test_led_count';
|
const _CSS_TEST_LED_KEY = 'css_test_led_count';
|
||||||
const _CSS_TEST_FPS_KEY = 'css_test_fps';
|
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 _cssTestWs: WebSocket | null = null;
|
||||||
let _cssTestRaf: number | null = null;
|
let _cssTestRaf: number | null = null;
|
||||||
let _cssTestLatestRgb: Uint8Array | 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 _cssTestCSPTMode: boolean = false; // true when testing a CSPT template
|
||||||
let _cssTestCSPTId: string | null = null; // CSPT template ID when in CSPT mode
|
let _cssTestCSPTId: string | null = null; // CSPT template ID when in CSPT mode
|
||||||
let _cssTestIsApiInput: boolean = false;
|
let _cssTestIsApiInput: boolean = false;
|
||||||
|
let _cssTestIsKeyColors: boolean = false;
|
||||||
let _cssTestFpsTimestamps: number[] = []; // raw timestamps for current-second FPS calculation
|
let _cssTestFpsTimestamps: number[] = []; // raw timestamps for current-second FPS calculation
|
||||||
let _cssTestFpsActualHistory: number[] = []; // rolling FPS samples for sparkline
|
let _cssTestFpsActualHistory: number[] = []; // rolling FPS samples for sparkline
|
||||||
let _cssTestFpsChart: any = null;
|
let _cssTestFpsChart: any = null;
|
||||||
@@ -125,6 +130,11 @@ function _getCssTestFps() {
|
|||||||
return (stored >= 1 && stored <= 60) ? stored : 20;
|
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) {
|
function _populateCssTestSourceSelector(preselectId: any) {
|
||||||
const sources = (colorStripSourcesCache.data || []) as any[];
|
const sources = (colorStripSourcesCache.data || []) as any[];
|
||||||
const nonProcessed = sources.filter(s => s.source_type !== 'processed');
|
const nonProcessed = sources.filter(s => s.source_type !== 'processed');
|
||||||
@@ -162,81 +172,139 @@ export function testColorStrip(sourceId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _kcTestWs: WebSocket | null = null;
|
let _kcTestWs: WebSocket | null = null;
|
||||||
const _kcTestCanvas = document.createElement('canvas');
|
|
||||||
const BORDER_COLORS = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96e6a1', '#dda0dd', '#f9ca24', '#ff9ff3', '#54a0ff'];
|
const BORDER_COLORS = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96e6a1', '#dda0dd', '#f9ca24', '#ff9ff3', '#54a0ff'];
|
||||||
|
|
||||||
function _testKeyColorsSource(sourceId: string) {
|
function _testKeyColorsSource(sourceId: string) {
|
||||||
// Show lightbox with spinner
|
_cssTestCSPTMode = false;
|
||||||
const lightbox = document.getElementById('image-lightbox')!;
|
_cssTestCSPTId = null;
|
||||||
const spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement | null;
|
_cssTestIsApiInput = false;
|
||||||
const content = lightbox.querySelector('.lightbox-content') as HTMLElement | null;
|
_cssTestIsKeyColors = true;
|
||||||
if (content) content.style.width = '90vw'; // Fill viewport for KC preview
|
_cssTestSourceId = sourceId;
|
||||||
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');
|
|
||||||
|
|
||||||
// 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; }
|
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
|
||||||
|
|
||||||
// Build WS URL
|
const gen = ++_cssTestGeneration;
|
||||||
const loc = window.location;
|
const loc = window.location;
|
||||||
const wsProto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
|
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) => {
|
openAuthedWs(wsUrl).then((ws) => {
|
||||||
|
if (gen !== _cssTestGeneration) { ws.close(); return; }
|
||||||
_kcTestWs = ws;
|
_kcTestWs = ws;
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
|
if (gen !== _cssTestGeneration) return;
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(ev.data);
|
const data = JSON.parse(ev.data);
|
||||||
if (data.type === 'frame') {
|
if (data.type === 'frame') {
|
||||||
_renderKCTestFrame(data);
|
_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); }
|
} catch (err) { logError('color-strips.test.kcWs.message', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
showToast('Key Colors test connection failed', 'error');
|
if (gen !== _cssTestGeneration) return;
|
||||||
closeLightbox();
|
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;
|
_kcTestWs = null;
|
||||||
};
|
if (ev.reason) {
|
||||||
|
const statusEl = document.getElementById('css-test-status') as HTMLElement;
|
||||||
// Stop WS when lightbox closes
|
statusEl.textContent = ev.reason;
|
||||||
const origClose = (window as any).closeLightbox;
|
statusEl.style.display = '';
|
||||||
lightbox.onclick = (e) => {
|
}
|
||||||
if ((e.target as HTMLElement).closest('.lightbox-content')) return;
|
|
||||||
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
|
|
||||||
closeLightbox();
|
|
||||||
};
|
};
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
showToast('Key Colors test connection failed', 'error');
|
if (gen !== _cssTestGeneration) return;
|
||||||
closeLightbox();
|
const statusEl = document.getElementById('css-test-status') as HTMLElement;
|
||||||
|
statusEl.textContent = t('color_strip.test.error');
|
||||||
|
statusEl.style.display = '';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _renderKCTestFrame(data: any) {
|
function _renderKCTestFrame(data: any) {
|
||||||
const rects = data.rectangles || [];
|
const rects = data.rectangles || [];
|
||||||
const mode = data.interpolation_mode || 'average';
|
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();
|
const tmpImg = new Image();
|
||||||
tmpImg.onload = () => {
|
tmpImg.onload = () => {
|
||||||
_kcTestCanvas.width = tmpImg.naturalWidth;
|
canvas.width = tmpImg.naturalWidth;
|
||||||
_kcTestCanvas.height = tmpImg.naturalHeight;
|
canvas.height = tmpImg.naturalHeight;
|
||||||
const ctx = _kcTestCanvas.getContext('2d')!;
|
const ctx = canvas.getContext('2d')!;
|
||||||
ctx.drawImage(tmpImg, 0, 0);
|
ctx.drawImage(tmpImg, 0, 0);
|
||||||
|
|
||||||
rects.forEach((r: any, i: number) => {
|
rects.forEach((r: any, i: number) => {
|
||||||
const x = r.x * _kcTestCanvas.width;
|
const x = r.x * canvas.width;
|
||||||
const y = r.y * _kcTestCanvas.height;
|
const y = r.y * canvas.height;
|
||||||
const w = r.width * _kcTestCanvas.width;
|
const w = r.width * canvas.width;
|
||||||
const h = r.height * _kcTestCanvas.height;
|
const h = r.height * canvas.height;
|
||||||
const borderColor = BORDER_COLORS[i % BORDER_COLORS.length];
|
const borderColor = BORDER_COLORS[i % BORDER_COLORS.length];
|
||||||
|
|
||||||
ctx.fillStyle = r.color.hex + '33';
|
ctx.fillStyle = r.color.hex + '33';
|
||||||
@@ -258,36 +326,19 @@ function _renderKCTestFrame(data: any) {
|
|||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.strokeRect(x + w - 24, y + 2, 22, 22);
|
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;
|
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} region${rects.length !== 1 ? 's' : ''}</span>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testCSPT(templateId: string) {
|
export async function testCSPT(templateId: string) {
|
||||||
@@ -310,10 +361,12 @@ export async function testCSPT(templateId: string) {
|
|||||||
function _openTestModal(sourceId: string) {
|
function _openTestModal(sourceId: string) {
|
||||||
// Clean up any previous session fully
|
// Clean up any previous session fully
|
||||||
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
|
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
|
||||||
|
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
|
||||||
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
|
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
|
||||||
_cssTestLatestRgb = null;
|
_cssTestLatestRgb = null;
|
||||||
_cssTestMeta = null;
|
_cssTestMeta = null;
|
||||||
_cssTestIsComposite = false;
|
_cssTestIsComposite = false;
|
||||||
|
_cssTestIsKeyColors = false;
|
||||||
_cssTestLayerData = null;
|
_cssTestLayerData = null;
|
||||||
|
|
||||||
const modal = document.getElementById('test-css-source-modal') as HTMLElement | 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-strip-view') as HTMLElement).style.display = 'none';
|
||||||
(document.getElementById('css-test-rect-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-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
|
// Clear all test canvases to prevent stale frames from previous sessions
|
||||||
modal.querySelectorAll('canvas').forEach(c => {
|
modal.querySelectorAll('canvas').forEach(c => {
|
||||||
const ctx = c.getContext('2d');
|
const ctx = c.getContext('2d');
|
||||||
@@ -363,8 +418,12 @@ function _openTestModal(sourceId: string) {
|
|||||||
|
|
||||||
const fpsVal = _getCssTestFps();
|
const fpsVal = _getCssTestFps();
|
||||||
const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null;
|
const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null;
|
||||||
fpsInput!.value = fpsVal as any;
|
if (fpsInput) {
|
||||||
fpsInput!.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
|
fpsInput.min = '1';
|
||||||
|
fpsInput.max = '60';
|
||||||
|
fpsInput.value = String(fpsVal);
|
||||||
|
fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
|
||||||
|
}
|
||||||
|
|
||||||
_cssTestConnect(sourceId, ledCount, fpsVal);
|
_cssTestConnect(sourceId, ledCount, fpsVal);
|
||||||
}
|
}
|
||||||
@@ -621,6 +680,18 @@ function _cssTestUpdateBrightness(values: any) {
|
|||||||
export function applyCssTestSettings() {
|
export function applyCssTestSettings() {
|
||||||
if (!_cssTestSourceId) return;
|
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;
|
const ledInput = document.getElementById('css-test-led-input') as HTMLInputElement | null;
|
||||||
let leds = parseInt(ledInput?.value ?? '', 10);
|
let leds = parseInt(ledInput?.value ?? '', 10);
|
||||||
if (isNaN(leds) || leds < 1) leds = 1;
|
if (isNaN(leds) || leds < 1) leds = 1;
|
||||||
@@ -1060,11 +1131,13 @@ function _cssTestStopFpsSampling() {
|
|||||||
|
|
||||||
export function closeTestCssSourceModal() {
|
export function closeTestCssSourceModal() {
|
||||||
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
|
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
|
||||||
|
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
|
||||||
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
|
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
|
||||||
_cssTestLatestRgb = null;
|
_cssTestLatestRgb = null;
|
||||||
_cssTestMeta = null;
|
_cssTestMeta = null;
|
||||||
_cssTestSourceId = null;
|
_cssTestSourceId = null;
|
||||||
_cssTestIsComposite = false;
|
_cssTestIsComposite = false;
|
||||||
|
_cssTestIsKeyColors = false;
|
||||||
_cssTestLayerData = null;
|
_cssTestLayerData = null;
|
||||||
_cssTestNotificationIds = [];
|
_cssTestNotificationIds = [];
|
||||||
_cssTestIsApiInput = false;
|
_cssTestIsApiInput = false;
|
||||||
|
|||||||
@@ -44,6 +44,14 @@
|
|||||||
<canvas id="css-test-layers-axis" class="css-test-strip-axis"></canvas>
|
<canvas id="css-test-layers-axis" class="css-test-strip-axis"></canvas>
|
||||||
</div>
|
</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) -->
|
<!-- CSPT test: input source selector (hidden by default) -->
|
||||||
<div id="css-test-cspt-input-group" style="display:none" class="css-test-led-control">
|
<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>
|
<label for="css-test-cspt-input-select" data-i18n="color_strip.processed.input">Source:</label>
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
<!-- Image Lightbox -->
|
<!-- Image Lightbox -->
|
||||||
<div id="image-lightbox" class="lightbox" onclick="closeLightbox(event)">
|
<div id="image-lightbox" class="lightbox" onclick="closeLightbox(event)">
|
||||||
<button class="lightbox-close" onclick="closeLightbox()" title="Close">✕</button>
|
<button class="lightbox-close" onclick="closeLightbox()" title="Close">✕</button>
|
||||||
<button id="lightbox-auto-refresh" class="lightbox-refresh-btn" onclick="toggleKCTestAutoRefresh()" title="Stream live" style="display:none">▶</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">
|
<div class="lightbox-content">
|
||||||
<img id="lightbox-image" src="" alt="Full size preview">
|
<img id="lightbox-image" src="" alt="Full size preview">
|
||||||
<div id="lightbox-stats" class="lightbox-stats" style="display: none;"></div>
|
<div id="lightbox-stats" class="lightbox-stats" style="display: none;"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user