Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ef6ac1317 | |||
| 0980cf4dde | |||
| fdac26b9d9 | |||
| 816a27db73 | |||
| 797b806972 | |||
| 9d4a534ec6 | |||
| 51eebf21d5 | |||
| 9067db2639 | |||
| 233b463ac3 | |||
| de13f44f24 | |||
| 1c9acc5afb | |||
| a56569b02f | |||
| ccf4406349 | |||
| 8aa3a323d6 | |||
| 8e109f32b9 | |||
| 033c1f6a92 | |||
| 0804f54537 | |||
| 66f921c07f | |||
| 80f01d4813 | |||
| b1ee3c3942 | |||
| e0ff40f4f5 | |||
| 3f80ef2101 | |||
| 2bae304107 | |||
| dd415e2813 | |||
| b43e1cf375 | |||
| 56853b7123 | |||
| 70c95d1c09 | |||
| e5a2af9821 | |||
| 539e43195f | |||
| c44bb38c43 | |||
| be2d5e1670 | |||
| 5db6eddcf8 | |||
| a8a4296a56 | |||
| 9ce1dc33bf | |||
| 03d2e6b1f2 | |||
| c2c9af3c60 | |||
| 4f7794ccd4 | |||
| a0d63a3663 | |||
| 35b75a2ed8 | |||
| 4ed099d564 | |||
| d467eb5dae | |||
| 524e422517 | |||
| 5d6310f28c | |||
| 7ef17c1595 | |||
| b3775b2f98 | |||
| 45f93fd30e | |||
| 2b5dac2c42 | |||
| d3a6416a1d | |||
| 123da1b5c4 | |||
| 5fcb9f82bd | |||
| 928d626620 | |||
| 580bd692e6 | |||
| 7fcb8dd346 | |||
| ecae05d00b | |||
| 546b24d015 | |||
| 488df98996 | |||
| 2477e00fae | |||
| 151cea3ecb | |||
| 8574424fb7 | |||
| a0b65e3fcb | |||
| 02cd9d519c | |||
| 38f73badbf | |||
| e678e5590a | |||
| 83ceaeda9d | |||
| d3cd48e7a7 | |||
| cc9900d801 | |||
| 4940007e54 | |||
| 92585e7c19 | |||
| 0e09eaf43b | |||
| adfc39f9d1 | |||
| d037a2e929 | |||
| fc8ee34369 | |||
| e262a8b004 | |||
| d4ffe2e985 | |||
| feb91ad281 | |||
| 17c5c02993 | |||
| fd6776aeac | |||
| 9f34ffb0a0 | |||
| b5842e6424 | |||
| 7a9c368448 | |||
| ce53ca6872 | |||
| b04978af58 | |||
| 6e8b159126 | |||
| ace24715c8 | |||
| edc6d27e2e | |||
| b7da4ab6b5 | |||
| 99460a8043 | |||
| 89990f8d63 | |||
| 0cc0aaa411 | |||
| af2c89c8df | |||
| d04192ffb7 | |||
| 992495e2e4 | |||
| 6b0e4e5539 | |||
| ce1f4847f3 | |||
| 1ce0dc6c61 | |||
| 553463935e | |||
| ab43578049 | |||
| 353c090b42 | |||
| eb94066386 | |||
| 86a9d344e6 | |||
| c59107c7c7 | |||
| e7c9a568dc | |||
| b36ddfd395 | |||
| 492bdb95e3 | |||
| b6713be390 | |||
| db5008aaeb | |||
| 4b7a8d75f4 | |||
| f6c25cd15f | |||
| 0a8737157c | |||
| 11d5d6b5e1 | |||
| 384362ccf1 | |||
| ea812bb4d5 | |||
| a9e6e8cb82 | |||
| 78ce6c84d7 | |||
| 8a17bb5caa | |||
| 5f70302263 | |||
| 40751fecb7 | |||
| 381ee75371 | |||
| 3e6760f726 | |||
| 89d1b13854 | |||
| 324a308805 | |||
| cb9289f01f | |||
| fb98e6e2b8 | |||
| 3c2efd5e4a | |||
| 2153dde4b7 | |||
| f3d07fc47f | |||
| f61a0206d4 | |||
| f345687600 | |||
| e2e1107df7 | |||
| c0853ce184 | |||
| 3e0bf8538c | |||
| be4c98b543 | |||
| dca2d212b1 | |||
| 53986f8d95 | |||
| a4a9f6f77f | |||
| 9fcfdb8570 |
@@ -0,0 +1,246 @@
|
||||
name: Build Android APK
|
||||
|
||||
on:
|
||||
# Release tags only — building the ~100 MB APK on every master push
|
||||
# burned Gitea runner minutes without producing a useful artifact.
|
||||
# Use workflow_dispatch for on-demand dev builds.
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version label (e.g. dev, 0.3.0-test)'
|
||||
required: false
|
||||
default: 'dev'
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
JAVA_VERSION: '17'
|
||||
PYTHON_VERSION: '3.11'
|
||||
ANDROID_CMDLINE_TOOLS_VERSION: '11076708'
|
||||
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
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve build label
|
||||
id: label
|
||||
run: |
|
||||
REF="${{ gitea.ref_name }}"
|
||||
if echo "$REF" | grep -qE '^v[0-9]'; then
|
||||
LABEL="${REF#v}"
|
||||
IS_RELEASE="true"
|
||||
elif [ -n "${{ inputs.version }}" ]; then
|
||||
LABEL="${{ inputs.version }}"
|
||||
IS_RELEASE="false"
|
||||
else
|
||||
LABEL="dev-${{ gitea.sha }}"
|
||||
IS_RELEASE="false"
|
||||
fi
|
||||
LABEL="${LABEL:0:40}"
|
||||
echo "label=$LABEL" >> "$GITHUB_OUTPUT"
|
||||
echo "is_release=$IS_RELEASE" >> "$GITHUB_OUTPUT"
|
||||
echo "Build label: $LABEL (release=$IS_RELEASE)"
|
||||
|
||||
- name: Setup JDK ${{ env.JAVA_VERSION }}
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Setup Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Setup Android SDK + NDK
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SDK_ROOT="$HOME/android-sdk"
|
||||
mkdir -p "$SDK_ROOT/cmdline-tools"
|
||||
cd "$SDK_ROOT/cmdline-tools"
|
||||
curl -sSL --retry 3 \
|
||||
-o cmdline-tools.zip \
|
||||
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip"
|
||||
unzip -q cmdline-tools.zip
|
||||
rm cmdline-tools.zip
|
||||
mv cmdline-tools latest
|
||||
|
||||
export ANDROID_SDK_ROOT="$SDK_ROOT"
|
||||
export ANDROID_HOME="$SDK_ROOT"
|
||||
SDKMANAGER="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager"
|
||||
|
||||
yes | "$SDKMANAGER" --licenses > /dev/null 2>&1 || true
|
||||
"$SDKMANAGER" --install \
|
||||
"platform-tools" \
|
||||
"platforms;${ANDROID_SDK_PLATFORM}" \
|
||||
"build-tools;${ANDROID_BUILD_TOOLS}" \
|
||||
"ndk;${ANDROID_NDK_VERSION}" > /dev/null
|
||||
|
||||
echo "ANDROID_SDK_ROOT=$SDK_ROOT" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_HOME=$SDK_ROOT" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_NDK_HOME=$SDK_ROOT/ndk/${ANDROID_NDK_VERSION}" >> "$GITHUB_ENV"
|
||||
echo "$SDK_ROOT/platform-tools" >> "$GITHUB_PATH"
|
||||
echo "$SDK_ROOT/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Create local.properties
|
||||
run: |
|
||||
echo "sdk.dir=$ANDROID_SDK_ROOT" > android/local.properties
|
||||
|
||||
- name: Link Python source (junction equivalent)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Chaquopy reads Python modules from android/app/src/main/python/
|
||||
# On Windows dev machines this is a directory junction; on Linux
|
||||
# CI use a symlink. The parent dir is .gitignore'd (the junction
|
||||
# target is the real content), so we have to create it first.
|
||||
mkdir -p android/app/src/main/python
|
||||
ln -sfn "$(pwd)/server/src/ledgrab" android/app/src/main/python/ledgrab
|
||||
ls -la android/app/src/main/python/
|
||||
# Sanity check — readlink resolves the link and the directory exists.
|
||||
test -d android/app/src/main/python/ledgrab
|
||||
|
||||
- name: Decode signing keystore
|
||||
id: keystore
|
||||
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:
|
||||
ANDROID_KEYSTORE_PATH: ${{ steps.keystore.outputs.path }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
chmod +x gradlew
|
||||
if [ "${{ steps.keystore.outputs.present }}" = "true" ] && [ "${{ steps.label.outputs.is_release }}" = "true" ]; then
|
||||
echo "Building signed release APK"
|
||||
./gradlew --no-daemon assembleRelease
|
||||
else
|
||||
echo "Building debug APK (no signing keystore available or not a release tag)"
|
||||
./gradlew --no-daemon assembleDebug
|
||||
fi
|
||||
|
||||
- name: Locate and rename APK
|
||||
id: apk
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SRC=$(ls android/app/build/outputs/apk/release/*.apk 2>/dev/null | head -1 || true)
|
||||
if [ -z "$SRC" ]; then
|
||||
SRC=$(ls android/app/build/outputs/apk/debug/*.apk | head -1)
|
||||
VARIANT="debug"
|
||||
else
|
||||
VARIANT="release"
|
||||
fi
|
||||
DEST="build/LedGrab-${{ steps.label.outputs.label }}-android-${VARIANT}.apk"
|
||||
mkdir -p build
|
||||
cp "$SRC" "$DEST"
|
||||
echo "path=$DEST" >> "$GITHUB_OUTPUT"
|
||||
echo "name=$(basename "$DEST")" >> "$GITHUB_OUTPUT"
|
||||
ls -lh "$DEST"
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: LedGrab-${{ steps.label.outputs.label }}-android
|
||||
path: ${{ steps.apk.outputs.path }}
|
||||
retention-days: 90
|
||||
|
||||
- name: Attach APK to Gitea release (upsert)
|
||||
if: ${{ steps.label.outputs.is_release == 'true' }}
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
APK_PATH="${{ steps.apk.outputs.path }}"
|
||||
APK_NAME="${{ steps.apk.outputs.name }}"
|
||||
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
echo "::error::DEPLOY_TOKEN secret not configured — cannot attach APK"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Upsert: look up release by tag. If it exists, reuse it; if 404,
|
||||
# create one. Makes the Android workflow self-sufficient — no
|
||||
# ordering dependency on release.yml's create-release job.
|
||||
HTTP=$(curl -s -o /tmp/release.json -w "%{http_code}" \
|
||||
"$BASE_URL/releases/tags/$TAG" \
|
||||
-H "Authorization: token $GITEA_TOKEN")
|
||||
case "$HTTP" in
|
||||
200)
|
||||
RELEASE_ID=$(python3 -c "import json; print(json.load(open('/tmp/release.json'))['id'])")
|
||||
echo "Found existing release id=$RELEASE_ID"
|
||||
;;
|
||||
404)
|
||||
echo "No release for tag $TAG — creating one"
|
||||
IS_PRE="false"
|
||||
if echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
|
||||
IS_PRE="true"
|
||||
fi
|
||||
CREATE_HTTP=$(curl -s -o /tmp/created.json -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/releases" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"LedGrab $TAG\",\"draft\":false,\"prerelease\":$IS_PRE}")
|
||||
if [ "$CREATE_HTTP" != "201" ] && [ "$CREATE_HTTP" != "200" ]; then
|
||||
echo "::error::Failed to create release (HTTP $CREATE_HTTP)"
|
||||
cat /tmp/created.json
|
||||
exit 1
|
||||
fi
|
||||
RELEASE_ID=$(python3 -c "import json; print(json.load(open('/tmp/created.json'))['id'])")
|
||||
echo "Created release id=$RELEASE_ID"
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unexpected HTTP $HTTP when looking up release for tag $TAG"
|
||||
cat /tmp/release.json
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Replace existing asset if present (re-run safety).
|
||||
EXISTING_ID=$(curl -fsS "$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']=='$APK_NAME'),''))")
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -fsS -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
|
||||
-H "Authorization: token $GITEA_TOKEN"
|
||||
echo "Replaced existing asset: $APK_NAME"
|
||||
fi
|
||||
|
||||
# -f: exit non-zero on 4xx/5xx so a broken token fails the job
|
||||
# loudly instead of the previous silent "Uploaded" lie.
|
||||
curl -fsS -X POST \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$APK_NAME" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$APK_PATH"
|
||||
echo "Uploaded: $APK_NAME"
|
||||
@@ -0,0 +1,80 @@
|
||||
name: Build Artifacts
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version label (e.g. dev, 0.3.0-test)'
|
||||
required: false
|
||||
default: 'dev'
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends zip libportaudio2 nsis msitools
|
||||
|
||||
- name: Cross-build Windows distribution
|
||||
run: |
|
||||
chmod +x build/build-dist-windows.sh
|
||||
./build/build-dist-windows.sh "v${{ inputs.version }}"
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: LedGrab-${{ inputs.version }}-win-x64
|
||||
path: |
|
||||
build/LedGrab-*.zip
|
||||
build/LedGrab-*-setup.exe
|
||||
retention-days: 90
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends libportaudio2
|
||||
|
||||
- name: Build Linux distribution
|
||||
run: |
|
||||
chmod +x build/build-dist.sh
|
||||
./build/build-dist.sh "v${{ inputs.version }}"
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: LedGrab-${{ inputs.version }}-linux-x64
|
||||
path: build/LedGrab-*.tar.gz
|
||||
retention-days: 90
|
||||
@@ -4,16 +4,30 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
# Manual dispatch builds Windows/Linux/Docker artifacts without creating
|
||||
# a Gitea release — for validating build scripts between real releases.
|
||||
# Attach/push steps are gated on github.event_name == 'push'.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version label for dispatch builds (artifacts only, no release)'
|
||||
required: false
|
||||
default: 'dev'
|
||||
|
||||
jobs:
|
||||
# ── Create the release first (shared by all build jobs) ────
|
||||
create-release:
|
||||
# Skipped on workflow_dispatch — dispatch is for build validation only.
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.create.outputs.release_id }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Fetch RELEASE_NOTES.md only
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: RELEASE_NOTES.md
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Create Gitea release
|
||||
id: create
|
||||
@@ -33,11 +47,9 @@ jobs:
|
||||
REPO=$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')
|
||||
DOCKER_IMAGE="${SERVER_HOST}/${REPO}"
|
||||
|
||||
# Scan for RELEASE_NOTES.md (check repo root first, then recursively)
|
||||
NOTES_FILE=$(find . -maxdepth 3 -name "RELEASE_NOTES.md" -type f | head -1)
|
||||
if [ -n "$NOTES_FILE" ]; then
|
||||
export RELEASE_NOTES=$(cat "$NOTES_FILE")
|
||||
echo "Found release notes: $NOTES_FILE"
|
||||
if [ -f RELEASE_NOTES.md ]; then
|
||||
export RELEASE_NOTES=$(cat RELEASE_NOTES.md)
|
||||
echo "Found RELEASE_NOTES.md"
|
||||
else
|
||||
export RELEASE_NOTES=""
|
||||
echo "No RELEASE_NOTES.md found"
|
||||
@@ -64,6 +76,7 @@ jobs:
|
||||
| Windows (installer) | \`LedGrab-{tag}-setup.exe\` | Install with Start Menu shortcut, optional autostart, uninstaller |
|
||||
| Windows (portable) | \`LedGrab-{tag}-win-x64.zip\` | Unzip anywhere, run LedGrab.bat |
|
||||
| Linux | \`LedGrab-{tag}-linux-x64.tar.gz\` | Extract, run ./run.sh |
|
||||
| Android | \`LedGrab-{tag}-android-release.apk\` | Sideload on Android 7.0+ (API 24+) — TV boxes, Fire TV, phones, tablets. arm64-v8a / x86_64 / x86 |
|
||||
| Docker | See below | docker pull + docker run |
|
||||
|
||||
After starting, open **http://localhost:8080** in your browser.
|
||||
@@ -77,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)))
|
||||
@@ -110,6 +123,9 @@ jobs:
|
||||
# ── Windows portable ZIP (cross-built from Linux) ─────────
|
||||
build-windows:
|
||||
needs: create-release
|
||||
# `!cancelled()` lets this job run even when create-release was skipped
|
||||
# (dispatch) or failed. The attach step itself is still push-gated.
|
||||
if: ${{ !cancelled() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -134,8 +150,8 @@ jobs:
|
||||
|
||||
- name: Cross-build Windows distribution
|
||||
run: |
|
||||
chmod +x build-dist-windows.sh
|
||||
./build-dist-windows.sh "${{ gitea.ref_name }}"
|
||||
chmod +x build/build-dist-windows.sh
|
||||
./build/build-dist-windows.sh "${{ gitea.ref_name }}"
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
@@ -147,6 +163,8 @@ jobs:
|
||||
retention-days: 90
|
||||
|
||||
- name: Attach assets to release
|
||||
# Push (tag) only — dispatch runs produce artifacts but no release.
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||
run: |
|
||||
@@ -173,15 +191,26 @@ 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:
|
||||
needs: create-release
|
||||
if: ${{ !cancelled() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -206,8 +235,8 @@ jobs:
|
||||
|
||||
- name: Build Linux distribution
|
||||
run: |
|
||||
chmod +x build-dist.sh
|
||||
./build-dist.sh "${{ gitea.ref_name }}"
|
||||
chmod +x build/build-dist.sh
|
||||
./build/build-dist.sh "${{ gitea.ref_name }}"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
@@ -217,34 +246,44 @@ jobs:
|
||||
retention-days: 90
|
||||
|
||||
- name: Attach tarball to release
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||
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
|
||||
if: ${{ !cancelled() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -268,6 +307,9 @@ jobs:
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
id: docker-login
|
||||
# Dispatch runs don't need registry credentials — the build step
|
||||
# verifies the Dockerfile locally and push is skipped.
|
||||
if: github.event_name == 'push'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "${{ secrets.DEPLOY_TOKEN }}" | docker login \
|
||||
@@ -275,7 +317,10 @@ jobs:
|
||||
-u "${{ gitea.actor }}" --password-stdin
|
||||
|
||||
- name: Build Docker image
|
||||
if: steps.docker-login.outcome == 'success'
|
||||
# Always build — dispatch uses this to validate the Dockerfile.
|
||||
# On push, still gate on successful login so we don't build a
|
||||
# tagged image that can't be pushed.
|
||||
if: github.event_name != 'push' || steps.docker-login.outcome == 'success'
|
||||
run: |
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
REGISTRY="${{ steps.meta.outputs.registry }}"
|
||||
@@ -294,7 +339,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Push Docker image
|
||||
if: steps.docker-login.outcome == 'success'
|
||||
if: github.event_name == 'push' && steps.docker-login.outcome == 'success'
|
||||
run: |
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
REGISTRY="${{ steps.meta.outputs.registry }}"
|
||||
|
||||
@@ -5,9 +5,15 @@ on:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
# Allow manual runs (e.g. to validate after a release commit was skipped).
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
# Skip release-publishing commits — version bumps don't affect lint/tests
|
||||
# and the release.yml pipeline is already running. PRs and manual dispatch
|
||||
# always run.
|
||||
if: ${{ github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'chore: release') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
+27
-3
@@ -4,7 +4,16 @@ __pycache__/
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
# Build output artifacts (LedGrab/, *.zip, *.exe, *.tar.gz, cached downloads)
|
||||
build/LedGrab/
|
||||
build/*.zip
|
||||
build/*.exe
|
||||
build/*.tar.gz
|
||||
build/*.msi
|
||||
build/python-embed-*.zip
|
||||
build/pip-wheels/
|
||||
build/win-wheels/
|
||||
build/tk-extract/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
@@ -16,6 +25,10 @@ parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
# …but keep pre-built Android wheels (pydantic-core cross-compiled for
|
||||
# arm64-v8a / x86_64 / x86, required by the Chaquopy build)
|
||||
!android/wheels/
|
||||
!android/wheels/*
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
@@ -49,8 +62,17 @@ 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/
|
||||
# Defensive: if the server is launched from server/src/ (uncommon path),
|
||||
# its relative `data/` dir resolves to server/src/data/. Templates now
|
||||
# live in SQLite, so any *.json that lands here is stale runtime export
|
||||
# and must not be committed.
|
||||
/server/src/data/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.json.bak
|
||||
@@ -73,3 +95,5 @@ tmp/
|
||||
# OS
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
# Added by code-review-graph
|
||||
.code-review-graph/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"code-review-graph": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"code-review-graph",
|
||||
"serve"
|
||||
],
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ repos:
|
||||
hooks:
|
||||
- id: black
|
||||
args: [--line-length=100, --target-version=py311]
|
||||
language_version: python3.11
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.0
|
||||
|
||||
-118
@@ -1,118 +0,0 @@
|
||||
# Feature Brainstorm — LED Grab
|
||||
|
||||
## New Automation Conditions (Profiles)
|
||||
|
||||
Right now profiles only trigger on **app detection**. High-value additions:
|
||||
|
||||
- **Time-of-day / Schedule** — "warm tones after sunset, off at midnight." Schedule-based value sources pattern already exists
|
||||
- **Display state** — detect monitor on/off/sleep, auto-stop targets when display is off
|
||||
- **System idle** — dim or switch to ambient effect after N minutes of no input
|
||||
- **Sunrise/sunset** — fetch local solar times, drive circadian color temperature shifts
|
||||
- **Webhook/MQTT trigger** — let external systems activate profiles without HA integration
|
||||
|
||||
## New Output Targets
|
||||
|
||||
Currently: WLED, Adalight, AmbileD, DDP. Potential:
|
||||
|
||||
- **MQTT publish** — generic IoT output, any MQTT subscriber becomes a target
|
||||
- **Art-Net / sACN (E1.31)** — stage/theatrical lighting protocols, DMX controllers
|
||||
- **OpenRGB** — control PC peripherals (keyboard, mouse, RAM, fans) as ambient targets
|
||||
- **HTTP webhook** — POST color data to arbitrary endpoints
|
||||
- **Recording target** — save color streams to file for playback later
|
||||
|
||||
## New Color Strip Sources
|
||||
|
||||
- **Spotify / media player** — album art color extraction or tempo-synced effects
|
||||
- **Weather** — pull conditions from API, map to palettes (blue=rain, orange=sun, white=snow)
|
||||
- **Camera / webcam** — border-sampling from camera feed for video calls or room-reactive lighting
|
||||
- **Script source** — user-written JS/Python snippets producing color arrays per frame
|
||||
- **Notification reactive** — flash/pulse on OS notifications (optional app filter)
|
||||
|
||||
## Processing Pipeline Extensions
|
||||
|
||||
- **Palette quantization** — force output to match a user-defined palette
|
||||
- **Zone grouping** — merge adjacent LEDs into logical groups sharing one averaged color
|
||||
- **Color temperature filter** — warm/cool shift separate from hue shift (circadian/mood)
|
||||
- **Noise gate** — suppress small color changes below threshold, preventing shimmer on static content
|
||||
|
||||
## Multi-Instance & Sync
|
||||
|
||||
- **Multi-room sync** — multiple instances with shared clock for synchronized effects
|
||||
- **Multi-display unification** — treat 2-3 monitors as single virtual display for seamless ambilight
|
||||
- **Leader/follower mode** — one target's output drives others with optional delay (cascade)
|
||||
|
||||
## UX & Dashboard
|
||||
|
||||
- **PWA / mobile layout** — mobile-first layout + "Add to Home Screen" manifest
|
||||
- **Scene presets** — bundled source + filters + brightness as one-click presets ("Movie night", "Gaming")
|
||||
- **Live preview on dashboard** — miniature screen with LED colors rendered around its border
|
||||
- **Undo/redo for calibration** — reduce frustration in the fiddly calibration editor
|
||||
- **Drag-and-drop filter ordering** — reorder postprocessing filter chains visually
|
||||
|
||||
## API & Integration
|
||||
|
||||
- **WebSocket event bus** — broadcast all state changes over a single WS channel
|
||||
- **OBS integration** — detect active scene, switch profiles; or use OBS virtual camera as source
|
||||
- **Plugin system** — formalize extension points into documented plugin API with hot-reload
|
||||
|
||||
## Creative / Fun
|
||||
|
||||
- **Effect sequencer** — timeline-based choreography of effects, colors, and transitions
|
||||
- **Music BPM sync** — lock effect speed to detected BPM (beat detection already exists)
|
||||
- **Color extraction from image** — upload photo, extract palette, use as gradient/cycle source
|
||||
- **Transition effects** — crossfade, wipe, or dissolve between sources/profiles instead of instant cut
|
||||
|
||||
---
|
||||
|
||||
## Deep Dive: Notification Reactive Source
|
||||
|
||||
**Type:** New `ColorStripSource` (`source_type: "notification"`) — normally outputs transparent RGBA, flashes on notification events. Designed to be used as a layer in a **composite source** so it overlays on top of a persistent base (gradient, effect, screen capture, etc.).
|
||||
|
||||
### Trigger modes (both active simultaneously)
|
||||
|
||||
1. **OS listener (Windows)** — `pywinrt` + `Windows.UI.Notifications.Management.UserNotificationListener`. Runs in background thread, pushes events to source via queue. Windows-only for now; macOS (`pyobjc` + `NSUserNotificationCenter`) and Linux (`dbus` + `org.freedesktop.Notifications`) deferred to future.
|
||||
2. **Webhook** — `POST /api/v1/notifications/{source_id}/fire` with optional body `{ "app": "MyApp", "color": "#FF0000" }`. Always available, cross-platform by nature.
|
||||
|
||||
### Source config
|
||||
|
||||
```yaml
|
||||
os_listener: true # enable Windows notification listener
|
||||
app_filter:
|
||||
mode: whitelist|blacklist # which apps to react to
|
||||
apps: [Discord, Slack, Telegram]
|
||||
app_colors: # user-configured app → color mapping
|
||||
Discord: "#5865F2"
|
||||
Slack: "#4A154B"
|
||||
Telegram: "#26A5E4"
|
||||
default_color: "#FFFFFF" # fallback when app has no mapping
|
||||
effect: flash|pulse|sweep # visual effect type
|
||||
duration_ms: 1500 # effect duration
|
||||
```
|
||||
|
||||
### Effect rendering
|
||||
|
||||
Source outputs RGBA color array per frame:
|
||||
- **Idle**: all pixels `(0,0,0,0)` — composite passes through base layer
|
||||
- **Flash**: instant full-color, linear fade to transparent over `duration_ms`
|
||||
- **Pulse**: sine fade in/out over `duration_ms`
|
||||
- **Sweep**: color travels across the strip like a wave
|
||||
|
||||
Each notification starts its own mini-timeline from trigger timestamp (not sync clock).
|
||||
|
||||
### Overlap handling
|
||||
|
||||
New notification while previous effect is active → restart timer with new color. No queuing.
|
||||
|
||||
### App color resolution
|
||||
|
||||
1. Webhook body `color` field (explicit override) → highest priority
|
||||
2. `app_colors` mapping by app name
|
||||
3. `default_color` fallback
|
||||
|
||||
---
|
||||
|
||||
## Top Picks (impact vs effort)
|
||||
|
||||
1. **Time-of-day + idle profile conditions** — builds on existing profile/condition architecture
|
||||
2. **MQTT output target** — opens the door to an enormous IoT ecosystem
|
||||
3. **Scene presets** — purely frontend, bundles existing features into one-click UX
|
||||
@@ -1,4 +1,4 @@
|
||||
# Claude Instructions for WLED Screen Controller
|
||||
# Claude Instructions for LedGrab
|
||||
|
||||
## Code Search
|
||||
|
||||
@@ -28,8 +28,21 @@ ast-index changed --base master # Show symbols changed in current bran
|
||||
## Project Structure
|
||||
|
||||
- `/server` — Python FastAPI backend (see [server/CLAUDE.md](server/CLAUDE.md))
|
||||
- `/android` — Android TV app (Kotlin shell + embedded Python via Chaquopy)
|
||||
- `/contexts` — Context files for Claude (frontend conventions, graph editor, Chrome tools, server ops, demo mode)
|
||||
|
||||
## Android Dependency Sync (CRITICAL)
|
||||
|
||||
The Android app (`android/app/build.gradle.kts`) installs the server package with `--no-deps` and lists Android-compatible dependencies **explicitly** in the Chaquopy `pip {}` block. This is because `server/pyproject.toml` includes desktop-only packages (mss, psutil, sounddevice, etc.) that have no Android wheels.
|
||||
|
||||
**When adding a new dependency to `server/pyproject.toml`:**
|
||||
|
||||
1. If the package is **pure Python or has Chaquopy wheels** (check [Chaquopy PyPI](https://chaquo.com/pypi-13.1/)), also add it to `android/app/build.gradle.kts` in the `pip { install(...) }` block
|
||||
2. If the package is **desktop-only** (native C/Rust extension without Android support), do NOT add it to `build.gradle.kts` — and guard its import with `try/except ImportError` in Python code
|
||||
3. If unsure, check Chaquopy's package index first
|
||||
|
||||
**Incident context:** Chaquopy's pip runs on the build machine (Windows), not on Android. Platform markers like `sys_platform != 'linux'` evaluate against the BUILD host, not the target device. `pip install --exclude` does not exist. The only reliable way to exclude packages is to not list them.
|
||||
|
||||
## Context Files
|
||||
|
||||
| File | When to read |
|
||||
@@ -91,3 +104,42 @@ Do NOT commit code that fails linting or tests. Fix the issues first.
|
||||
- Follow existing code style and patterns
|
||||
- Update documentation when changing behavior
|
||||
- Never make commits or pushes without explicit user approval
|
||||
|
||||
<!-- code-review-graph MCP tools -->
|
||||
## MCP Tools: code-review-graph
|
||||
|
||||
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
||||
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
||||
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
||||
you structural context (callers, dependents, test coverage) that file
|
||||
scanning cannot.
|
||||
|
||||
### When to use graph tools FIRST
|
||||
|
||||
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
||||
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
||||
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
||||
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
||||
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
||||
|
||||
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||
|
||||
### Key Tools
|
||||
|
||||
| Tool | Use when |
|
||||
|------|----------|
|
||||
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
||||
| `get_review_context` | Need source snippets for review — token-efficient |
|
||||
| `get_impact_radius` | Understanding blast radius of a change |
|
||||
| `get_affected_flows` | Finding which execution paths are impacted |
|
||||
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
||||
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
||||
| `get_architecture_overview` | Understanding high-level codebase structure |
|
||||
| `refactor_tool` | Planning renames, finding dead code |
|
||||
|
||||
### Workflow
|
||||
|
||||
1. The graph auto-updates on file changes (via hooks).
|
||||
2. Use `detect_changes` for code review.
|
||||
3. Use `get_affected_flows` to understand impact.
|
||||
4. Use `query_graph` pattern="tests_for" to check coverage.
|
||||
|
||||
+4
-4
@@ -9,8 +9,8 @@
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
|
||||
cd wled-screen-controller-mixed/server
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
|
||||
cd ledgrab/server
|
||||
|
||||
# Python environment
|
||||
python -m venv venv
|
||||
@@ -29,7 +29,7 @@ npm run build
|
||||
cd server
|
||||
export PYTHONPATH=$(pwd)/src # Linux/Mac
|
||||
# set PYTHONPATH=%CD%\src # Windows
|
||||
python -m wled_controller.main
|
||||
python -m ledgrab.main
|
||||
```
|
||||
|
||||
Open http://localhost:8080 to access the dashboard.
|
||||
@@ -55,7 +55,7 @@ ruff check src/ tests/
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
After modifying any file under `server/src/wled_controller/static/js/` or `static/css/`, rebuild the bundle:
|
||||
After modifying any file under `server/src/ledgrab/static/js/` or `static/css/`, rebuild the bundle:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
|
||||
+29
-80
@@ -1,15 +1,17 @@
|
||||
# Installation Guide
|
||||
|
||||
Complete installation guide for LED Grab (WLED Screen Controller) server and Home Assistant integration.
|
||||
Complete installation guide for the LedGrab server.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Docker Installation (recommended)](#docker-installation)
|
||||
2. [Manual Installation](#manual-installation)
|
||||
3. [First-Time Setup](#first-time-setup)
|
||||
4. [Home Assistant Integration](#home-assistant-integration)
|
||||
5. [Configuration Reference](#configuration-reference)
|
||||
6. [Troubleshooting](#troubleshooting)
|
||||
4. [Configuration Reference](#configuration-reference)
|
||||
5. [Troubleshooting](#troubleshooting)
|
||||
|
||||
> **Home Assistant integration** has moved to a separate repository:
|
||||
> [ledgrab-haos-integration](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab-haos-integration)
|
||||
|
||||
---
|
||||
|
||||
@@ -20,8 +22,8 @@ The fastest way to get running. Requires [Docker](https://docs.docker.com/get-do
|
||||
1. **Clone and start:**
|
||||
|
||||
```bash
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
|
||||
cd wled-screen-controller/server
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
|
||||
cd ledgrab/server
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
@@ -54,7 +56,7 @@ cd server
|
||||
docker build -t ledgrab .
|
||||
|
||||
docker run -d \
|
||||
--name wled-screen-controller \
|
||||
--name ledgrab \
|
||||
-p 8080:8080 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
-v $(pwd)/logs:/app/logs \
|
||||
@@ -84,8 +86,8 @@ Screen capture from inside a container requires X11 access. Uncomment `network_m
|
||||
1. **Clone the repository:**
|
||||
|
||||
```bash
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
|
||||
cd wled-screen-controller/server
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
|
||||
cd ledgrab/server
|
||||
```
|
||||
|
||||
2. **Build the frontend bundle:**
|
||||
@@ -95,7 +97,7 @@ Screen capture from inside a container requires X11 access. Uncomment `network_m
|
||||
npm run build
|
||||
```
|
||||
|
||||
This compiles TypeScript and bundles JS/CSS into `src/wled_controller/static/dist/`.
|
||||
This compiles TypeScript and bundles JS/CSS into `src/ledgrab/static/dist/`.
|
||||
|
||||
3. **Create a virtual environment:**
|
||||
|
||||
@@ -123,7 +125,6 @@ Screen capture from inside a container requires X11 access. Uncomment `network_m
|
||||
```bash
|
||||
pip install ".[perf]" # DXCam, BetterCam, WGC (Windows only)
|
||||
pip install ".[notifications]" # OS notification capture
|
||||
pip install ".[tray]" # System tray icon (Windows only)
|
||||
pip install ".[dev]" # pytest, black, ruff (development)
|
||||
```
|
||||
|
||||
@@ -132,11 +133,11 @@ Screen capture from inside a container requires X11 access. Uncomment `network_m
|
||||
```bash
|
||||
# Linux / macOS
|
||||
export PYTHONPATH=$(pwd)/src
|
||||
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||
uvicorn ledgrab.main:app --host 0.0.0.0 --port 8080
|
||||
|
||||
# Windows (cmd)
|
||||
set PYTHONPATH=%CD%\src
|
||||
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||
uvicorn ledgrab.main:app --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
6. **Verify:** open <http://localhost:8080> in your browser.
|
||||
@@ -161,7 +162,7 @@ auth:
|
||||
Option B -- set an environment variable:
|
||||
|
||||
```bash
|
||||
export WLED_AUTH__API_KEYS__dev="your-secure-key-here"
|
||||
export LEDGRAB_AUTH__API_KEYS__dev="your-secure-key-here"
|
||||
```
|
||||
|
||||
Generate a random key:
|
||||
@@ -185,7 +186,7 @@ server:
|
||||
Or via environment variable:
|
||||
|
||||
```bash
|
||||
WLED_SERVER__CORS_ORIGINS='["http://localhost:8080","http://192.168.1.100:8080"]'
|
||||
LEDGRAB_SERVER__CORS_ORIGINS='["http://localhost:8080","http://192.168.1.100:8080"]'
|
||||
```
|
||||
|
||||
### Discover devices
|
||||
@@ -194,57 +195,12 @@ Open the dashboard and go to the **Devices** tab. Click **Discover** to find WLE
|
||||
|
||||
---
|
||||
|
||||
## Home Assistant Integration
|
||||
|
||||
### Option 1: HACS (recommended)
|
||||
|
||||
1. Install [HACS](https://hacs.xyz/docs/setup/download) if you have not already.
|
||||
2. Open HACS in Home Assistant.
|
||||
3. Click the three-dot menu, then **Custom repositories**.
|
||||
4. Add URL: `https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed`
|
||||
5. Set category to **Integration** and click **Add**.
|
||||
6. Search for "WLED Screen Controller" in HACS and click **Download**.
|
||||
7. Restart Home Assistant.
|
||||
8. Go to **Settings > Devices & Services > Add Integration** and search for "WLED Screen Controller".
|
||||
9. Enter your server URL (e.g., `http://192.168.1.100:8080`) and API key.
|
||||
|
||||
### Option 2: Manual
|
||||
|
||||
Copy the `custom_components/wled_screen_controller/` folder from this repository into your Home Assistant `config/custom_components/` directory, then restart Home Assistant and add the integration as above.
|
||||
|
||||
### Automation example
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Start ambient lighting when TV turns on"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: media_player.living_room_tv
|
||||
to: "on"
|
||||
action:
|
||||
- service: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.living_room_tv_processing
|
||||
|
||||
- alias: "Stop ambient lighting when TV turns off"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: media_player.living_room_tv
|
||||
to: "off"
|
||||
action:
|
||||
- service: switch.turn_off
|
||||
target:
|
||||
entity_id: switch.living_room_tv_processing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
The server reads configuration from three sources (in order of priority):
|
||||
|
||||
1. **Environment variables** -- prefix `WLED_`, double underscore as nesting delimiter (e.g., `WLED_SERVER__PORT=9090`)
|
||||
2. **YAML config file** -- `server/config/default_config.yaml` (or set `WLED_CONFIG_PATH` to override)
|
||||
1. **Environment variables** -- prefix `LEDGRAB_`, double underscore as nesting delimiter (e.g., `LEDGRAB_SERVER__PORT=9090`)
|
||||
2. **YAML config file** -- `server/config/default_config.yaml` (or set `LEDGRAB_CONFIG_PATH` to override)
|
||||
3. **Built-in defaults**
|
||||
|
||||
See [`server/.env.example`](server/.env.example) for every available variable with descriptions.
|
||||
@@ -253,14 +209,14 @@ See [`server/.env.example`](server/.env.example) for every available variable wi
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `WLED_SERVER__PORT` | `8080` | HTTP listen port |
|
||||
| `WLED_SERVER__LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
||||
| `WLED_SERVER__CORS_ORIGINS` | `["http://localhost:8080"]` | Allowed CORS origins (JSON array) |
|
||||
| `WLED_AUTH__API_KEYS` | `{"dev":"development-key..."}` | API keys (JSON object) |
|
||||
| `WLED_STORAGE__DATABASE_FILE` | `data/ledgrab.db` | SQLite database path |
|
||||
| `WLED_MQTT__ENABLED` | `false` | Enable MQTT for HA auto-discovery |
|
||||
| `WLED_MQTT__BROKER_HOST` | `localhost` | MQTT broker address |
|
||||
| `WLED_DEMO` | `false` | Enable demo mode (sandbox with virtual devices) |
|
||||
| `LEDGRAB_SERVER__PORT` | `8080` | HTTP listen port |
|
||||
| `LEDGRAB_SERVER__LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
||||
| `LEDGRAB_SERVER__CORS_ORIGINS` | `["http://localhost:8080"]` | Allowed CORS origins (JSON array) |
|
||||
| `LEDGRAB_AUTH__API_KEYS` | `{"dev":"development-key..."}` | API keys (JSON object) |
|
||||
| `LEDGRAB_STORAGE__DATABASE_FILE` | `data/ledgrab.db` | SQLite database path |
|
||||
| `LEDGRAB_MQTT__ENABLED` | `false` | Enable MQTT for HA auto-discovery |
|
||||
| `LEDGRAB_MQTT__BROKER_HOST` | `localhost` | MQTT broker address |
|
||||
| `LEDGRAB_DEMO` | `false` | Enable demo mode (sandbox with virtual devices) |
|
||||
|
||||
---
|
||||
|
||||
@@ -277,7 +233,7 @@ python --version # must be 3.11+
|
||||
**Check the frontend bundle exists:**
|
||||
|
||||
```bash
|
||||
ls server/src/wled_controller/static/dist/app.bundle.js
|
||||
ls server/src/ledgrab/static/dist/app.bundle.js
|
||||
```
|
||||
|
||||
If missing, run `cd server && npm ci && npm run build`.
|
||||
@@ -289,7 +245,7 @@ If missing, run `cd server && npm ci && npm run build`.
|
||||
docker compose logs -f
|
||||
|
||||
# Manual install
|
||||
tail -f logs/wled_controller.log
|
||||
tail -f logs/ledgrab.log
|
||||
```
|
||||
|
||||
### Cannot access the dashboard from another machine
|
||||
@@ -298,13 +254,6 @@ tail -f logs/wled_controller.log
|
||||
2. Check your firewall allows inbound traffic on port 8080.
|
||||
3. Add your server's LAN IP to `cors_origins` (see [Configure CORS](#configure-cors-for-lan-access) above).
|
||||
|
||||
### Home Assistant integration not appearing
|
||||
|
||||
1. Verify HACS installed the component: check that `config/custom_components/wled_screen_controller/` exists.
|
||||
2. Clear your browser cache.
|
||||
3. Restart Home Assistant.
|
||||
4. Check logs at **Settings > System > Logs** and search for `wled_screen_controller`.
|
||||
|
||||
### WLED device not responding
|
||||
|
||||
1. Confirm the device is powered on and connected to Wi-Fi.
|
||||
@@ -325,4 +274,4 @@ tail -f logs/wled_controller.log
|
||||
|
||||
- [API Documentation](docs/API.md)
|
||||
- [Calibration Guide](docs/CALIBRATION.md)
|
||||
- [Repository Issues](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues)
|
||||
- [Repository Issues](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/issues)
|
||||
|
||||
@@ -87,8 +87,8 @@ A Home Assistant integration exposes devices as entities for smart home automati
|
||||
### Docker (recommended)
|
||||
|
||||
```bash
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
|
||||
cd wled-screen-controller/server
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
|
||||
cd ledgrab/server
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
@@ -97,8 +97,8 @@ docker compose up -d
|
||||
Requires Python 3.11+ and Node.js 18+.
|
||||
|
||||
```bash
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
|
||||
cd wled-screen-controller/server
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
|
||||
cd ledgrab/server
|
||||
|
||||
# Build the frontend bundle
|
||||
npm ci && npm run build
|
||||
@@ -112,7 +112,7 @@ pip install .
|
||||
# Start the server
|
||||
export PYTHONPATH=$(pwd)/src # Linux/Mac
|
||||
# set PYTHONPATH=%CD%\src # Windows
|
||||
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||
uvicorn ledgrab.main:app --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
Open **http://localhost:8080** to access the dashboard.
|
||||
@@ -121,12 +121,32 @@ Open **http://localhost:8080** to access the dashboard.
|
||||
|
||||
See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including configuration, Docker manual builds, and Home Assistant setup.
|
||||
|
||||
## Demo Mode
|
||||
|
||||
Demo mode runs the server with virtual devices, sample data, and isolated storage — useful for exploring the UI without real hardware.
|
||||
|
||||
Set the `LEDGRAB_DEMO` environment variable to `true`, `1`, or `yes`:
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
docker compose run -e LEDGRAB_DEMO=true server
|
||||
|
||||
# Python
|
||||
LEDGRAB_DEMO=true uvicorn ledgrab.main:app --host 0.0.0.0 --port 8081
|
||||
|
||||
# Windows (installed app)
|
||||
set LEDGRAB_DEMO=true
|
||||
LedGrab.bat
|
||||
```
|
||||
|
||||
Demo mode uses port **8081**, config file `config/demo_config.yaml`, and stores data in `data/demo/` (separate from production data). It can run alongside the main server.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
wled-screen-controller/
|
||||
ledgrab/
|
||||
├── server/ # Python FastAPI backend
|
||||
│ ├── src/wled_controller/
|
||||
│ ├── src/ledgrab/
|
||||
│ │ ├── main.py # Application entry point
|
||||
│ │ ├── config.py # YAML + env var configuration
|
||||
│ │ ├── api/
|
||||
@@ -151,8 +171,6 @@ wled-screen-controller/
|
||||
│ ├── tests/ # pytest suite
|
||||
│ ├── Dockerfile
|
||||
│ └── docker-compose.yml
|
||||
├── custom_components/ # Home Assistant integration (HACS)
|
||||
│ └── wled_screen_controller/
|
||||
├── docs/
|
||||
│ ├── API.md # REST API reference
|
||||
│ └── CALIBRATION.md # LED calibration guide
|
||||
@@ -162,7 +180,7 @@ wled-screen-controller/
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `server/config/default_config.yaml` or use environment variables with the `WLED_` prefix:
|
||||
Edit `server/config/default_config.yaml` or use environment variables with the `LEDGRAB_` prefix:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
@@ -180,11 +198,11 @@ storage:
|
||||
|
||||
logging:
|
||||
format: "json"
|
||||
file: "logs/wled_controller.log"
|
||||
file: "logs/ledgrab.log"
|
||||
max_size_mb: 100
|
||||
```
|
||||
|
||||
Environment variable override example: `WLED_SERVER__PORT=9090`.
|
||||
Environment variable override example: `LEDGRAB_SERVER__PORT=9090`.
|
||||
|
||||
## API
|
||||
|
||||
@@ -214,9 +232,7 @@ See [docs/CALIBRATION.md](docs/CALIBRATION.md) for a step-by-step guide.
|
||||
|
||||
## Home Assistant
|
||||
|
||||
Install via HACS (add as a custom repository) or manually copy `custom_components/wled_screen_controller/` into your HA config directory. The integration creates light, switch, sensor, and number entities for each configured device.
|
||||
|
||||
See [INSTALLATION.md](INSTALLATION.md) for detailed setup instructions.
|
||||
For Home Assistant integration, see the separate [ledgrab-haos-integration](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab-haos-integration) repository.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
+51
-3
@@ -1,7 +1,42 @@
|
||||
## v0.2.2 (2025-03-25)
|
||||
## v0.6.0 (2026-05-01)
|
||||
|
||||
This release adds **device-event notifications** (snack + Web Notifications), a **daylight/timezone-aware streaming pipeline** with a new camera engine, a **redesigned Targets surface** built on the dashboard's mod-card system, a **tighter LED hot path** with allocation-free per-frame work, and a **revamped Release Notes overlay** with clickable asset downloads. Plus a wide pass of modal, toolbar, and settings polish across the WebUI.
|
||||
|
||||
### Features
|
||||
- Add 4 built-in gradients, searchable gradient picker, cleaner modal titles ([a5e7a4e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a5e7a4e))
|
||||
- **Device event notifications** — configurable per-event channel matrix (none / snack / OS / both) for target online/offline, new WLED/serial discovery, and devices going missing. Backed by a long-running mDNS browser + 10 s serial poller, a startup-grace / flap-debounce / bulk-coalesce pipeline, and a new Notifications tab in Settings (en/ru/zh). ([8aa3a32](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8aa3a32))
|
||||
- **Daylight + timezone streaming** — new `daylight_settings` module and `daylight-tz` frontend helper expand the daylight stream's behavior; capture path additions land alongside a new **camera engine** test suite. ([fdac26b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdac26b))
|
||||
- **Targets cards migrated to the mod-card system** — LED targets and HA Light targets now share the dashboard's instrument-readout vocabulary (mod-head / mod-leds / mod-metrics / mod-foot, kebab menu, badges, chips, patch indicator). LED preview, FPS sparkline, and pipeline metrics preserved via an `extraHtml` escape hatch. ([233b463](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/233b463))
|
||||
- **Target pipeline as a compact strip + chip row** — drops the legacy "Pipeline details" collapsible block; an always-visible 4 px segmented timing bar (extract / map / smooth / send for video, read / fft / render / send for audio) sits above an inline chip row showing total ms / frames / keepalives, animating smoothly between samples. ([51eebf2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/51eebf2))
|
||||
- **Targets metrics aligned with the dashboard** — FPS sparkline now lives inside the FPS cell, Uptime gets a clock icon, Errors gets ok/warning by count, FPS readout adopts the dashboard `current/target avg N.N` shape, and the grid sizes so values like `1m 43s` no longer truncate at typical desktop widths. ([9067db2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9067db2))
|
||||
- **Release Notes overlay v2** — new masthead with display-font title, tag/published/pre-release chip strip, and close/external actions; markdown body fuzzy-matches `<code>` filenames to release assets and renders clickable download links with per-asset descriptions (Windows installer/portable/msi, Linux tarball/AppImage/deb/rpm, macOS dmg/pkg, Android apk/aab, iOS ipa). Checksum/signature side-files are hidden. ([9d4a534](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9d4a534))
|
||||
- **Tutorials expansion** — sub-tab switching, breadcrumb header, and prepare/switchSubTab hooks let tours open/close the dashboard customize panel and resolve targets behind sub-tabs; new steps for integrations, dashboard customize panel (presets / global / sections / perf cells), targets, scenes, and sync-clocks (en/ru/zh). ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
|
||||
- **Cards / settings / modal / toolbar polish** — reworked mod-card colors, sections, channel-stripe styling, hairline borders, and signal-flow animation on running cards; multiselect bulk toolbar gets explicit Select-all / Deselect-all icons with luxury-gradient toolbar styling; Settings tabs are now icon-only (no overflow at any locale); modal exit animation gains symmetric fadeOut + slideDown keyframes with reduced-motion support; locale picker collapses to EN / RU / ZH; snack toast adopts a glass background with per-type accent. ([a56569b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a56569b))
|
||||
- **Suppress browser auto-open on Windows login** — when "Start with Windows" is enabled, the autostart shortcut now passes `--autostart` so the WebUI tab no longer pops on every login. Manual launches and the installer's "Launch LedGrab" finish-page action are unchanged. ([de13f44](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/de13f44))
|
||||
- **Simpler segment payloads** — `SegmentPayload.start` defaults to 0 and `length` defaults to "the rest of the strip from start". A single segment with only `mode` + `color` now fills the entire strip — no more `length: 9999` magic value clients had to pass. ([1c9acc5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c9acc5))
|
||||
- About panel now houses the author + contact details that previously lived in a global app footer, freeing up vertical space across every page (en/ru/zh `donation.about_author` key added). ([816a27d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/816a27d))
|
||||
|
||||
### Performance
|
||||
- **LED hot path is allocation-free per-frame**: Adalight gets a dedicated single-worker tx executor, pre-allocated wire buffer, uint8 scratch, and a precomputed header struct; DDP gets a pre-built `struct.Struct` and memoryview emit path; calibration precomputes Phase 3 skip-LED resampling so per-frame work is now `np.take` + in-place blend; the WLED target processor gets a matching tightening. ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
|
||||
|
||||
### Bug Fixes
|
||||
- **Audio-source modal preserves device on refresh** — refresh button moved into the label row (no more overflow past the Source panel edge); selection is restored by matching on `(index, loopback)` first with a trimmed-name fallback for OS-side reindexing; the EntitySelect trigger now syncs so the visible label matches the underlying `<select>` in edit mode. ([0980cf4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0980cf4))
|
||||
- **PWA meta tag** — add the standard `mobile-web-app-capable` tag while keeping the Apple variant for iOS Safari, since Chrome deprecated `apple-mobile-web-app-capable`. ([8e109f3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8e109f3))
|
||||
|
||||
---
|
||||
|
||||
### Development / Internal
|
||||
|
||||
#### CI/Build
|
||||
- Add `workflow_dispatch` and skip lint/test on release commits (release.yml already runs in parallel; manual dispatch covers re-runs on demand). ([033c1f6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/033c1f6))
|
||||
|
||||
#### Tests
|
||||
- New `test_camera_engine` suite covers the new capture path. ([fdac26b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdac26b))
|
||||
- Adalight + DDP tests cover header format, buffer reuse, non-contiguous input, brightness scaling, RGB/RGBW packets, sequence/PUSH semantics, and multi-packet fragmentation. ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
|
||||
- 13 new tests for the device-event notifications backend (full suite still 899 passing). ([8aa3a32](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8aa3a32))
|
||||
- `conftest` pre-creates the test DB so `main.py`'s legacy-data migration no longer shovels the user's production DB into the test temp dir; `test_preferences_notifications` wipes its own setting at the start of the defaults test (was relying on isolation it never enforced). ([9d4a534](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9d4a534))
|
||||
|
||||
#### Tooling
|
||||
- `.mcp.json` checked in with code-review-graph MCP server config so the graph tools are available out of the box. ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
|
||||
|
||||
---
|
||||
|
||||
@@ -10,6 +45,19 @@
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------|---------|--------|
|
||||
| [a5e7a4e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a5e7a4e) | feat: add 4 built-in gradients, searchable gradient picker, cleaner modal titles | alexei.dolgolyov |
|
||||
| [0980cf4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0980cf4) | fix(ui): audio-source modal — preserve device on refresh, relocate refresh action | alexei.dolgolyov |
|
||||
| [fdac26b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdac26b) | feat: daylight tz, camera engine, value stream + modal/UI polish | alexei.dolgolyov |
|
||||
| [816a27d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/816a27d) | refactor(ui): drop app footer, move author info to About panel | alexei.dolgolyov |
|
||||
| [797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806) | feat: LED hot-path perf, tutorials expansion, modal markup polish | alexei.dolgolyov |
|
||||
| [9d4a534](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9d4a534) | feat(ui): release notes overlay v2 + settings/streams/dashboard polish | alexei.dolgolyov |
|
||||
| [51eebf2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/51eebf2) | feat(ui): redesign target pipeline as compact strip + chip row | alexei.dolgolyov |
|
||||
| [9067db2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9067db2) | feat(ui): align Targets metric cells with dashboard pattern | alexei.dolgolyov |
|
||||
| [233b463](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/233b463) | feat(ui): migrate Targets cards to mod-card system | alexei.dolgolyov |
|
||||
| [de13f44](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/de13f44) | feat(autostart): suppress browser auto-open on Windows login | alexei.dolgolyov |
|
||||
| [1c9acc5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c9acc5) | feat(api-input): make SegmentPayload start/length optional | alexei.dolgolyov |
|
||||
| [a56569b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a56569b) | feat(ui): cards redesign + settings, modal, toolbar polish | alexei.dolgolyov |
|
||||
| [8aa3a32](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8aa3a32) | feat(notifications): device event notifications (snack + Web Notifications) | alexei.dolgolyov |
|
||||
| [8e109f3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8e109f3) | fix(pwa): add mobile-web-app-capable meta tag | alexei.dolgolyov |
|
||||
| [033c1f6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/033c1f6) | ci: add workflow_dispatch and skip lint/test on release commits | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
# 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 `wled_controller` 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
|
||||
|
||||
- [ ] 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
|
||||
- [ ] Include a link to the donation page (GitHub Sponsors, Ko-fi, or similar — decide on platform)
|
||||
- [ ] Remember dismissal in localStorage so it doesn't reappear every session
|
||||
- [ ] Add i18n keys for the banner text (`en.json`, `ru.json`, `zh.json`)
|
||||
|
||||
---
|
||||
|
||||
# Color Strip Source Improvements
|
||||
|
||||
## New Source Types
|
||||
|
||||
- [x] **`weather`** — Weather-reactive ambient: maps weather conditions (rain, snow, clear, storm) to colors/animations via API
|
||||
- [ ] **`music_sync`** — Beat-synced patterns: BPM detection, energy envelope, drop detection (higher-level than raw `audio`)
|
||||
- [ ] **`math_wave`** — Mathematical wave generator: user-defined sine/triangle/sawtooth expressions, superposition
|
||||
- [ ] **`text_scroll`** — Scrolling text marquee: bitmap font rendering, static text or RSS/API data source *(delayed)*
|
||||
|
||||
### Discuss: `home_assistant`
|
||||
|
||||
Need to research HAOS communication options first (WebSocket API, REST API, MQTT, etc.) before deciding scope.
|
||||
|
||||
### Deferred
|
||||
|
||||
- `image` — Static image sampler *(not now)*
|
||||
- `clock` — Time display *(not now)*
|
||||
|
||||
## Improvements to Existing Sources
|
||||
|
||||
### `effect` (now 12 types)
|
||||
|
||||
- [x] Add effects: rain, comet, bouncing ball, fireworks, sparkle rain, lava lamp, wave interference
|
||||
- [x] Custom palette support: user-defined [[pos,R,G,B],...] stops via JSON textarea
|
||||
|
||||
### `gradient`
|
||||
|
||||
- [x] Noise-perturbed gradient: value noise displacement on stop positions (`noise_perturb` animation type)
|
||||
- [x] Gradient hue rotation: `hue_rotate` animation type — preserves S/V, rotates H
|
||||
- [x] Easing functions between stops: linear, ease_in_out (smoothstep), step, cubic
|
||||
|
||||
### `audio`
|
||||
|
||||
- [x] New audio source type: band extractor (bass/mid/treble split) — responsibility of audio source layer, not CSS
|
||||
- [ ] Peak hold indicator: global option on audio source (not per-mode), configurable decay time
|
||||
|
||||
### `daylight`
|
||||
|
||||
- [x] Longitude support for accurate solar position (NOAA solar equations)
|
||||
- [x] Season awareness (day-of-year drives sunrise/sunset via solar declination)
|
||||
|
||||
### `candlelight`
|
||||
|
||||
- [x] Wind simulation: correlated flicker bursts across all candles (wind_strength 0.0-2.0)
|
||||
- [x] Candle type presets: taper (steady), votive (flickery), bonfire (chaotic) — applied at render time
|
||||
- [x] Wax drip effect: localized brightness dips with fade-in/fade-out recovery
|
||||
|
||||
### `composite`
|
||||
|
||||
- [ ] Allow nested composites (with cycle detection)
|
||||
- [x] More blend modes: overlay, soft light, hard light, difference, exclusion
|
||||
- [x] Per-layer LED range masks (optional start/end/reverse on each composite layer)
|
||||
|
||||
### `notification`
|
||||
|
||||
- [x] Chase effect (light bounces across strip with glowing tail)
|
||||
- [x] Gradient flash (bright center fades to edges, exponential decay)
|
||||
- [x] Queue priority levels (color_override = high priority, interrupts current)
|
||||
|
||||
### `api_input`
|
||||
|
||||
- [ ] Crossfade transition when new data arrives
|
||||
- [ ] Interpolation when incoming LED count differs from strip count
|
||||
- [ ] Last-write-wins from any client (no multi-source blending)
|
||||
|
||||
## Architectural / Pipeline
|
||||
|
||||
### Processing Templates (CSPT)
|
||||
|
||||
- [x] HSL shift filter (hue rotation + lightness adjustment)
|
||||
- [x] ~~Color temperature filter~~ — already exists as `color_correction`
|
||||
- [x] Contrast filter
|
||||
- [x] ~~Saturation filter~~ — already exists
|
||||
- [x] ~~Pixelation filter~~ — already exists as `pixelate`
|
||||
- [x] Temporal blur filter (blend frames over time)
|
||||
|
||||
### Transition Engine
|
||||
|
||||
Needs deeper design discussion. Likely a new entity type `ColorStripSourceTransition` that defines how source switches happen (crossfade, wipe, etc.). Interacts with automations when they switch a target's active source.
|
||||
|
||||
### Deferred
|
||||
|
||||
- Global BPM sync *(not sure)*
|
||||
- Recording/playback *(not now)*
|
||||
- Source preview in editor modal *(not needed — overlay preview on devices is sufficient)*
|
||||
|
||||
---
|
||||
|
||||
## Remaining Open Discussion
|
||||
|
||||
1. **`home_assistant` source** — Need to research HAOS communication protocols first
|
||||
2. **Transition engine** — Design as `ColorStripSourceTransition` entity: what transition types? (crossfade, wipe, dissolve?) How does a target reference its transition config? How do automations trigger it?
|
||||
@@ -0,0 +1,54 @@
|
||||
# TODO
|
||||
|
||||
## 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] Include a link to the donation page (GitHub Sponsors, Ko-fi, or similar — decide on platform)
|
||||
- [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,37 +1,442 @@
|
||||
# Build Size Reduction
|
||||
# LedGrab TODO
|
||||
|
||||
## Phase 1: Quick Wins (build scripts)
|
||||
## Device Event Notifications
|
||||
|
||||
- [x] Strip unused NumPy submodules (polynomial, linalg, ma, lib, distutils)
|
||||
- [x] Strip debug symbols from .pyd/.dll/.so files
|
||||
- [x] Remove zeroconf service database
|
||||
- [x] Remove .py source from site-packages after compiling to .pyc
|
||||
- [x] Strip unused PIL image plugins (keep JPEG/PNG/ICO/BMP for tray)
|
||||
Notify the user when LED devices come online/go offline (configured targets), and when new
|
||||
WLED/serial devices are discovered or disappear from the LAN/USB. Each event class has a
|
||||
configurable channel: `none` | `snack` | `os` | `both`. OS channel uses Web Notifications
|
||||
(works in any browser tab and in the PWA shell — no platform-specific Python).
|
||||
|
||||
## Phase 2: Replace Pillow with cv2
|
||||
Branch: `feat/device-event-notifications`. Default ON.
|
||||
|
||||
- [x] Create `utils/image_codec.py` with cv2-based image helpers
|
||||
- [x] Replace PIL in `_preview_helpers.py`
|
||||
- [x] Replace PIL in `picture_sources.py`
|
||||
- [x] Replace PIL in `color_strip_sources.py`
|
||||
- [x] Replace PIL in `templates.py`
|
||||
- [x] Replace PIL in `postprocessing.py`
|
||||
- [x] Replace PIL in `output_targets_keycolors.py`
|
||||
- [x] Replace PIL in `kc_target_processor.py`
|
||||
- [x] Replace PIL in `pixelate.py` filter
|
||||
- [x] Replace PIL in `downscaler.py` filter
|
||||
- [x] Replace PIL in `scrcpy_engine.py`
|
||||
- [x] Replace PIL in `live_stream_manager.py`
|
||||
- [x] Move Pillow from core deps to [tray] optional in pyproject.toml
|
||||
- [x] Make PIL import conditional in `tray.py`
|
||||
- [x] Move opencv-python-headless to core dependencies
|
||||
### Backend
|
||||
|
||||
## Phase 4: OpenCV stripping (build scripts)
|
||||
- [x] `core/devices/discovery_watcher.py` — long-running mDNS browser
|
||||
(`AsyncServiceBrowser` kept alive for the process lifetime) + 10 s serial-port
|
||||
poller. Fires `device_discovered`/`device_lost` via `processor_manager.fire_event`,
|
||||
suppresses events for URLs already in `device_store`. Seeded ports do NOT generate
|
||||
startup-time toasts.
|
||||
- [x] Wired into `lifespan` (`main.py`). Gated by `notification_preferences.
|
||||
background_discovery_enabled`. Default True. Stops before health monitor stop.
|
||||
- [x] `api/schemas/preferences.py` — `NotificationPreferences` Pydantic v2 model with
|
||||
the 4-event channel matrix, `background_discovery_enabled`, `startup_grace_sec`
|
||||
(0..300), `flap_debounce_sec` (0..60).
|
||||
- [x] `api/routes/preferences.py` — `GET/PUT /api/v1/preferences/notifications`,
|
||||
persisted under `db.set_setting("notification_preferences", …)`. Corrupt stored
|
||||
values fall back to defaults instead of 500.
|
||||
- [x] Reuses existing `device_health_changed` event from `device_health.py` (already
|
||||
fires online/offline transitions on the same event bus).
|
||||
- [x] Tests: 7 in `tests/test_preferences_notifications_api.py`, 6 in
|
||||
`tests/test_discovery_watcher.py`. Full pytest suite still 899 passing.
|
||||
|
||||
- [x] Strip ffmpeg DLL, Haar cascades, dev files (already existed)
|
||||
- [x] Strip typing stubs (already existed)
|
||||
### Frontend
|
||||
|
||||
## Verification
|
||||
- [x] `js/features/notifications-watcher.ts` — listens to the three `server:*` DOM
|
||||
events. Applies user prefs. Pipeline: startup grace → flap debounce → bulk
|
||||
coalesce (≥3 events / 800 ms collapse to one summary).
|
||||
- [x] Web Notification permission requested from the Settings → Notifications panel
|
||||
via a user-gesture button. State chip reflects granted/denied/default.
|
||||
- [x] Settings panel — new "Notifications" subtab between Backup and Appearance.
|
||||
4 IconSelects (`none`/`snack`/`os`/`both`) + background-discovery toggle +
|
||||
permission row + Test-notification button.
|
||||
- [x] i18n: `settings.notifications.*` and `notifications.*` keys in en/ru/zh.
|
||||
|
||||
- [x] Lint: `ruff check src/ tests/ --fix`
|
||||
- [x] Tests: 341 passed
|
||||
### Verification
|
||||
|
||||
- [x] `npx tsc --noEmit` clean, `npm run build` produces 2.5 MB bundle.
|
||||
- [x] `ruff check src/ tests/` clean. 899/899 pytest pass.
|
||||
- [x] App import smoke-test (`from ledgrab.main import app`) loads 233 routes
|
||||
without errors.
|
||||
- [ ] Real-hardware test pending — verify on user's network:
|
||||
(1) plug a fresh WLED in → snack toast appears, (2) configure it → next
|
||||
offline transition fires both snack + OS toast, (3) Background-discovery
|
||||
toggle off → no more discovered/lost events.
|
||||
|
||||
### Out of scope for v1
|
||||
|
||||
- Per-device-type granularity (we ship one matrix per event-type, no device-type split)
|
||||
- Per-device mute list (deferred — user can globally toggle off if noisy)
|
||||
- Native OS toast via Windows winrt API (Web Notifications cover the use case;
|
||||
also avoids the `os_notification_listener` feedback loop)
|
||||
- Notification history panel — could land later as the reserved `alerts` dashboard cell
|
||||
|
||||
## Server shutdown action
|
||||
|
||||
Let user choose what happens to LED targets on server shutdown.
|
||||
|
||||
- [x] Backend storage: `shutdown_action` in `db.settings` (`"stop_targets"` default | `"nothing"`)
|
||||
- [x] Backend route: `GET/PUT /api/v1/system/shutdown-action` in `system_settings.py`
|
||||
- [x] Backend schema: `ShutdownActionResponse/Request` in `schemas/system.py`
|
||||
- [x] Backend wiring: lifespan shutdown in `main.py` reads action, passes `restore_devices` flag to `processor_manager.stop_all()`
|
||||
- [x] `processor_manager.stop_all(restore_devices: bool = True)` — when False, calls public `proc.cancel_task()` (defined on `TargetProcessor`) which awaits cancellation without restoring device state; skips `_restore_device_idle_state` loop. No reach into private `_task` attribute.
|
||||
- [x] Frontend: hidden `<select>` + IconSelect in `settings.html` General tab (icons via `ICON_SQUARE` / `ICON_CIRCLE` from `core/icons.ts`)
|
||||
- [x] Frontend: load/save handlers in `features/settings.ts`, wired into `openSettingsModal()`
|
||||
- [x] i18n: en / ru / zh keys for label, hint, item descriptions
|
||||
- [ ] Real-hardware test pending — verify that "nothing" actually leaves a WLED + a serial device on the last frame after `Ctrl+C`/SIGTERM.
|
||||
|
||||
## WebUI Redesign — "Lumenworks" Studio-Console Aesthetic
|
||||
|
||||
Full-app UI/UX refresh. Design direction committed to by user 2026-04-24.
|
||||
Mockup lives at [server/docs/ui-redesign-mockup.html](server/docs/ui-redesign-mockup.html).
|
||||
Phases are independent and CSS-only where possible — backend untouched.
|
||||
|
||||
### Phase 1 — Design tokens & font embed
|
||||
|
||||
- [x] Embed variable fonts (`server/src/ledgrab/static/fonts/`):
|
||||
Manrope (latin + latin-ext + cyrillic + cyrillic-ext),
|
||||
JetBrains Mono (same 4 subsets),
|
||||
Big Shoulders Display (latin + latin-ext). Total +201 KB gzipped,
|
||||
served via `unicode-range` so only latin paints on first load.
|
||||
- [x] `fonts.css` — declare `@font-face` entries for all new families with
|
||||
proper `unicode-range` subsetting; keep DM Sans + Orbitron registered
|
||||
for legacy-token callers during migration.
|
||||
- [x] `base.css` — add additive Lumenworks tokens:
|
||||
`--font-display/--font-brand/--font-body`, `--lux-r-*`, `--lux-hairline`,
|
||||
`--lux-rule`. Both `[data-theme="dark"]` and `[data-theme="light"]`
|
||||
define `--lux-bg-0…3`, `--lux-line/-bold`, `--lux-ink/-dim/-mute/-faint`,
|
||||
`--ch-signal/-cyan/-magenta/-amber/-coral/-violet`, `--lux-signal-glow`,
|
||||
`--lux-shadow-rack`. Existing tokens untouched — no visual regression.
|
||||
|
||||
### Phase 2 — Shell (header → transport bar + channel-strip sidebar)
|
||||
|
||||
- [x] `index.html` — `.tab-bar` moved out of `<header>` into a new
|
||||
`<aside class="sidebar">`; wrapped content in `.app-body` 2-col grid
|
||||
(sidebar | main). `.transport-center` section added between
|
||||
`.header-title` and `.header-toolbar` with a placeholder `.transport-status`
|
||||
chip ("Ready" → "Armed · N live" wired in Phase 3). All tab-button IDs,
|
||||
`data-tab` attributes, and `onclick="switchTab(…)"` handlers preserved.
|
||||
- [x] `layout.css` — `<header>` rebuilt as the transport bar: 3-column grid
|
||||
(brand | center | toolbar), 60 px fixed height, sticky, gradient bottom
|
||||
rule with channel-color wash. `.header-title::before/::after` render
|
||||
the glowing LED brand mark; `#server-status` repositioned as the LED
|
||||
core pip. `#server-version` restyled as a mono-type console badge.
|
||||
- [x] `sidebar.css` (new) — vertical channel-strip navigation. Active tab
|
||||
gets a glowing left stripe + radial tint. `.sidebar-foot` contains
|
||||
a `.cpu-meter` plate with two live bars (Load, FPS) ready to be
|
||||
JS-bound in Phase 3. Collapses to a 56 px icon rail at ≤1100 px;
|
||||
hides entirely at ≤600 px via `display: contents` so `.tab-bar`
|
||||
falls through to `mobile.css`'s fixed-bottom strip unchanged.
|
||||
- [x] `all.css` — new sidebar import after layout.
|
||||
- [x] `base.css` — body font-family switched to `var(--font-body)` which
|
||||
resolves to Manrope (with DM Sans + system fallbacks). Added
|
||||
`font-feature-settings` for stylistic set + alternate 1.
|
||||
- [x] Locale additions: `sidebar.workspaces`, `sidebar.load`, `sidebar.fps`,
|
||||
`transport.status.ready`, `transport.status.armed` in en/ru/zh.
|
||||
- [x] Tutorial + auth selectors (`header .header-title`, `#tab-btn-*`,
|
||||
`.tab-bar` querySelector, `a.header-link[href="/docs"]`, onclick
|
||||
markers on theme/settings/search) all survive the move.
|
||||
- [ ] JS: bind `.cpu-meter` + `.transport-status` chip to existing
|
||||
`performance` WebSocket / poller. Done as part of Phase 3.
|
||||
- [ ] Tablet-range visual polish pass once other phases render (some tabs
|
||||
currently have their own internal sticky headers that may overlap
|
||||
the transport bar on narrow viewports).
|
||||
|
||||
### Phase 3 — Dashboard hero + module redesign
|
||||
|
||||
- [x] `cards.css` — `.card` gets rack-module treatment: channel stripe on
|
||||
left edge (color-coded via `data-card-type` + `.ch-*` utility classes),
|
||||
`::after` corner bracket in top-right, mono-typed metric labels
|
||||
planned for Phase 4. Running cards glow the stripe brighter + emit a
|
||||
`signalFlow` keyframe strip along the bottom edge.
|
||||
- [x] Removed the `@property --border-angle` rotating conic-gradient border
|
||||
(retired the WebKit mask workaround + light-theme variant + fallback
|
||||
for `@supports not (mask-composite: exclude)`). Replaced with the
|
||||
signal-flow strip — one animated linear-gradient on a 2 px line, no
|
||||
GPU layer compositing per card.
|
||||
- [x] `dashboard.css` — `.dashboard-target` rows pick up the same channel
|
||||
stripe + signal-flow treatment. Section headers now use mono caps
|
||||
with a channel-green underline accent. Metric values use mono with
|
||||
tabular numerics; labels use silkscreened micro-caps.
|
||||
- [x] Skeleton-card rewritten: left hairline + corner bracket so it reads
|
||||
as "loading module" instead of a generic flashing block.
|
||||
`skeletonShimmer` gradient replaces the old opacity-pulse on
|
||||
`--text-color`.
|
||||
- [x] `_updateSidebarMeter` binds CPU% (Load) and app-CPU share (FPS)
|
||||
to the sidebar meter plate on every perf poll.
|
||||
- [x] `_updateTransportStatus` updates the transport chip ("Ready" →
|
||||
"Armed · N live") whenever the dashboard's running-target set is
|
||||
recomputed.
|
||||
- [ ] `.hero` 4-cell readout row (Active Patches / Throughput / CPU /
|
||||
Latency + inline sparklines) — CSS tokens + layout are ready; HTML
|
||||
render deferred until the dashboard JS is refactored to emit it
|
||||
(Phase 3b, non-blocking).
|
||||
|
||||
### Phase 4 — Other tabs adopt module language
|
||||
|
||||
- [x] `tree-nav.css` — trigger pill gets a channel stripe on its left edge
|
||||
(glows + widens when open). Trigger title uses mono-uppercase with
|
||||
wide letter-spacing. Dropdown panel has a gradient channel-accent
|
||||
rule across its top edge. Group headers use silkscreened micro-caps
|
||||
with a small square marker instead of the old bold-uppercase. Active
|
||||
leaf has a pulsing LED pip on the left and a channel tint behind it.
|
||||
Count badges switched to mono tabular-nums in 2-px-radius pills.
|
||||
- [x] `.subtab-section-header` — channel-green underline accent + mono
|
||||
micro-caps. Consistent with the dashboard-section pattern so the
|
||||
whole app shares one section-header language.
|
||||
- [x] `.stream-tab-btn` sub-tabs — mono uppercase with wide tracking,
|
||||
active tab shows channel-green underline + glowing count badge.
|
||||
- [x] `.perf-chart-card` — channel stripe on the left (replaces old
|
||||
`border-top` accent). Per-metric accents swapped to channel palette
|
||||
(`--ch-coral` for CPU, `--ch-violet` for RAM, `--ch-signal` for GPU,
|
||||
`--ch-amber` for temp). Corner bracket added. Metric values pick up
|
||||
`tabular-nums` + a soft glow.
|
||||
- [x] `cards.css` — channel-color mapping extended to attributes the JS
|
||||
already emits (`data-target-id` → green, `data-stream-id` → cyan,
|
||||
`data-audio-source-id` → magenta, `data-automation-id` /
|
||||
`data-scene-id` → violet). No JS changes required; cards pick up
|
||||
their correct stripe automatically on the Targets/Sources/Automations
|
||||
tabs.
|
||||
- [x] Graph editor — toolbar gets a gradient background + hairline +
|
||||
rack shadow + backdrop blur. Canvas and nodes untouched.
|
||||
- [x] `.template-card` — Lumenworks treatment (channel stripe on left,
|
||||
corner bracket top-right, hairline border, hover lift + stripe
|
||||
glow). Brings Inputs (streams / capture / pp / cspt / pattern
|
||||
templates) and Integrations (HA / MQTT / weather / value /
|
||||
sync-clock / game-integration cards) up to the same visual
|
||||
language as `.card` and `.dashboard-target`.
|
||||
- [x] `cards.css` — channel mapping extended to `.template-card`.
|
||||
Direct attr hooks for `data-stream-id`/`data-template-id`/`data-pp-template-id`
|
||||
(cyan), `data-cspt-id`/`data-pattern-template-id` (signal),
|
||||
`data-audio-template-id`/`data-apt-id` (magenta). Section-scoped
|
||||
hooks via `[data-card-section="…"]` for cards that share a
|
||||
generic `data-id` (HA / MQTT / weather / value → cyan;
|
||||
game-integrations → amber; sync-clocks → violet; HA-light-targets
|
||||
→ signal). No JS changes — uses the section markup `CardSection`
|
||||
already emits.
|
||||
- [x] Graph editor nodes — body fill `--lux-bg-1` with hairline stroke,
|
||||
hover bold-line, selected/running stroke `--ch-signal` with
|
||||
drop-shadow glow. Title font switched from DM Sans to
|
||||
`--font-display`; subtitle to mono uppercase wide-tracking.
|
||||
Port-drop-target glow recoloured to `--ch-signal`. Port labels
|
||||
adopt the mono caption treatment. Grid dots use `--lux-line`.
|
||||
Running gradient stops switched from `--primary-color`/`--success-color`
|
||||
to channel palette (signal → cyan → signal).
|
||||
|
||||
### Phase 5 — Modal restyle
|
||||
|
||||
- [x] `modal.css` — backdrop gains a radial dim + 6 px blur for stronger
|
||||
separation. `.modal-content` gets a gradient background + hairline +
|
||||
deep rack shadow. Channel-accent rule across the top edge driven by
|
||||
`--modal-ch` (per-modal override). Corner bracket bottom-right on
|
||||
desktop. `.modal-header` gains a vertical channel-color stripe to
|
||||
the left of the title; `.modal-footer` picks up a hairline divider.
|
||||
- [x] Per-modal channel mapping by modal ID:
|
||||
- Target editors → green
|
||||
- Input/Source editors → cyan
|
||||
- Audio editors → magenta
|
||||
- Automation / Scene / Game editors → violet
|
||||
- Settings / API key / Setup / Notifications → amber
|
||||
- Confirm dialog → coral
|
||||
- [x] `components.css` — inputs use hairline borders, tabular-nums mono
|
||||
for `input[type="number"]`, channel-green focus ring + glow. Buttons
|
||||
use mono-uppercase type, signal-glow on primary, coral-glow on
|
||||
danger. `<select>` audit deferred (project already enforces via
|
||||
CLAUDE.md rule + IconSelect/EntitySelect wrappers).
|
||||
|
||||
### Phase 6 — Mobile dedicated shell
|
||||
|
||||
- [x] `mobile.css` (existing file, not forked) — fixed-bottom `.tab-bar`
|
||||
promoted to full Lumenworks treatment: gradient background + hairline
|
||||
divider at top + channel-accent rule matching the transport-bar
|
||||
bottom. Active tab gets an LED pip above the icon and a channel-tint
|
||||
background. Tab labels + badges use mono uppercase to match the
|
||||
rest of the app. Phone (≤600 px): modal corner-bracket hidden
|
||||
(fullscreen modals), modal-header stripe slimmed to 18 px.
|
||||
- [x] Phase 2's layout.css already strips the transport-center on phones
|
||||
and collapses the sidebar via `display: contents`, so the mobile
|
||||
shell automatically routes the tab-bar to the bottom without a
|
||||
separate JS hook.
|
||||
- [WONTDO] Fork into `mobile-shell.css` — keeping changes in `mobile.css`
|
||||
since the cascade was already organized by viewport. A rename adds
|
||||
churn without improving maintainability.
|
||||
|
||||
### Phase 7 — Microcopy + retire legacy
|
||||
|
||||
- [x] Locale rename: `targets.title` + `dashboard.section.targets` →
|
||||
"Channels" (en) / "Каналы" (ru) / "通道" (zh);
|
||||
`streams.title` → "Inputs" / "Входы" / "输入".
|
||||
Automations kept as-is (Automations + Scenes is a meaningful
|
||||
distinction; "Patches" would conflate them). Internal tab keys
|
||||
(`dashboard` / `automations` / `targets` / `streams` / `integrations`
|
||||
/ `graph`) unchanged so no JS or localStorage migration needed.
|
||||
- [x] Ambient WebGL background — default is already `off`; kept the
|
||||
toggle button and localStorage preference so users who want the
|
||||
shader can turn it on. No entry-point change needed: `data-bg-anim`
|
||||
is initialized from localStorage with `off` fallback.
|
||||
- [DEFERRED] Delete DM Sans + legacy color tokens — would cascade through
|
||||
every file that reads `--primary-color` / `--text-color` etc. Safer
|
||||
as a separate cleanup PR after the new design has soaked.
|
||||
- [WONTDO] Delete `mobile.css` — Phase 6 kept the filename.
|
||||
|
||||
## Dashboard Customization
|
||||
|
||||
Per-account dashboard layout — slide-in Customize panel lets users
|
||||
toggle section / perf-cell visibility, reorder via drag, change density,
|
||||
pick presets, and import/export the layout as JSON. Server-synced via
|
||||
`db.get_setting('dashboard_layout')` so settings follow the user.
|
||||
|
||||
- [x] `js/features/dashboard-layout.ts` — schema (open registry of section
|
||||
/ perf-cell keys so v1.1 cards slot in with no migration), defaults,
|
||||
5 built-in presets (Studio/Operator/Showrunner/Diagnostics/TV),
|
||||
localStorage cache + server sync, legacy-key migration from
|
||||
`dashboard_collapsed`, `perfMetricsMode`, `perfChartColor_*`.
|
||||
- [x] `api/routes/preferences.py` — `GET/PUT/DELETE
|
||||
/api/v1/preferences/dashboard-layout`. Treats payload as opaque
|
||||
(frontend owns the schema); validates only that body is an object
|
||||
with a numeric `version`. 6 pytest tests in
|
||||
`tests/test_preferences_api.py` cover round-trip, default-empty,
|
||||
validation, delete, and unknown-field passthrough.
|
||||
- [x] `js/features/dashboard.ts` — sections rendered into a fragment map,
|
||||
then assembled in layout-driven order; perf section stays pinned
|
||||
top (chart-persistence reasons) but its visibility is layout-
|
||||
driven. Layout-change subscription invalidates the in-place-update
|
||||
optimization so density / order / visibility changes always
|
||||
rebuild section HTML.
|
||||
- [x] `js/features/perf-charts.ts` — `renderPerfSection()` iterates
|
||||
`getOrderedPerfCells()`; existing legacy `setPerfMode` writes
|
||||
through to the layout so the global toggle and the customize
|
||||
panel stay in sync.
|
||||
- [x] `js/features/dashboard-customize.ts` + `css/dashboard-customize.css`
|
||||
— slide-in panel, hand-rolled HTML5 drag-and-drop reorder, ↑/↓
|
||||
buttons for keyboard / TV remote, debounced (300 ms) autosave,
|
||||
live preview while open. Reset / export / import actions.
|
||||
- [x] i18n keys for `dashboard.customize.*` in en/ru/zh.
|
||||
- [ ] (v1.1) Audio meters section — peak / RMS / BPM bars per audio
|
||||
source. Schema key `audio-meters` already reserved.
|
||||
- [ ] (v1.1) Alerts section — quiet by default, loud on issues.
|
||||
Reserved key `alerts`.
|
||||
- [ ] (v1.1) Live LED preview strip per running device. Reserved
|
||||
key `led-preview`.
|
||||
- [ ] (v1.1) Source thumbnails grid (1 fps multiviewer). Reserved
|
||||
key `source-thumbs`.
|
||||
- [ ] (v1.2) Pinned section (user-curated mix of targets / scenes /
|
||||
devices). Reserved key `pinned`.
|
||||
- [ ] (v1.2) Patch/flow map — read-only mini graph of routing.
|
||||
Reserved key `flow`.
|
||||
|
||||
## BLE LED Controller Support (SP110E / Triones / Zengge / Govee)
|
||||
|
||||
Add support for Bluetooth Low Energy LED controllers driven by mobile apps like "LED Hue", HappyLighting, iLightsIn. Whole-strip ambient-color output only — these protocols don't support per-pixel streaming.
|
||||
|
||||
- [x] Add `bleak>=0.22` as optional extra `[ble]` in `server/pyproject.toml` (desktop-only, NOT in android `build.gradle.kts`)
|
||||
- [x] `core/devices/ble_transport.py` — bleak wrapper: scan, connect, write-with/without-response
|
||||
- [x] `core/devices/ble_protocols/` package
|
||||
- [x] `__init__.py` — `BLEProtocol` dataclass + registry (family → encoder)
|
||||
- [x] `sp110e.py` — SP110E / SP108E (service FFE0, char FFE1, `RR GG BB 00 1E` static-color frame)
|
||||
- [x] `triones.py` — Triones / HappyLighting / LEDnet (service FFE5, char FFE9, `7E 07 05 03 RR GG BB 10 EF`)
|
||||
- [x] `zengge.py` — Zengge / iLightsIn (service FFE0, framing `56 RR GG BB 00 F0 AA`)
|
||||
- [x] `govee.py` — Govee unencrypted framed protocol (AES keyed variants — marked experimental)
|
||||
- [x] `core/devices/ble_client.py` — unified `BLEClient(LEDClient)` — picks protocol by `ble_family`, averages strip → one color, drops duplicate frames, rate-limits to BLE connection interval
|
||||
- [x] `core/devices/ble_provider.py` — `BLEDeviceProvider` + discovery via `BleakScanner`
|
||||
- [x] Register in `core/devices/led_client.py::_register_builtin_providers` (guarded `try/except ImportError`)
|
||||
- [x] Storage: `ble_family`, `ble_govee_key` fields threaded through `Device.__init__`/`to_dict`/`from_dict`/`_UPDATABLE_FIELDS`/`create_device`
|
||||
- [x] Schemas: BLE fields on `DeviceCreate`, `DeviceUpdate`, `DeviceResponse`
|
||||
- [x] Routes: BLE fields propagated through create/update in `api/routes/devices.py` + `_device_to_response`
|
||||
- [x] ProcessorManager: `ble_family`/`ble_govee_key` added to `_DEVICE_FIELD_DEFAULTS` and `DeviceInfo`; passed through `wled_target_processor.py` and `group_client.py` to `create_led_client`
|
||||
- [x] Tests: 21 protocol encoder unit tests + 16 BLEClient fake-transport tests — all passing, 814 total tests still green
|
||||
- [x] Frontend: BLE option in the device type picker with a bluetooth Lucide icon; add-device modal shows a 4-option `IconSelect` for protocol family (SP110E / Triones / Zengge / Govee) with a Govee-only AES key field that auto-hides for the other three families; URL label/placeholder/hint adapt to `ble://<address>` pattern; submit payload carries `ble_family` (+ optional `ble_govee_key`); clone flow pre-fills family and key; modal dirty-check snapshots the new fields; network scan button now also discovers BLE peripherals via the existing `/api/v1/devices/discover?device_type=ble` endpoint
|
||||
- [x] Frontend: `isBleDevice` helper in `core/api.ts`; `ICON_BLUETOOTH` + `ICON_LIGHTBULB` constants in `core/icons.ts`; `bluetooth` path in `core/icon-paths.ts`; i18n keys in `en.json` / `ru.json` / `zh.json`; TypeScript compiles; esbuild bundle rebuilt
|
||||
- [x] Android BLE via Kotlin bridge — `BleBridge.kt` singleton (scan/connect/write/disconnect); `android_ble_transport.py` Python wrapper; `make_transport()` factory in `ble_transport.py` auto-selects backend; `BleBridge.init()` called from `LedGrabApp.onCreate`; BLE permissions in `AndroidManifest.xml`
|
||||
- [x] Govee per-model AES key — `_encrypt_govee_frame()` in `ble_client.py` uses AES-128-ECB from `cryptography`; key validated on `BLEClient` construction; applied to both `send_pixels` and `set_power`; 8 new AES unit tests
|
||||
|
||||
## Android — Restore Multi-ABI Wheels
|
||||
|
||||
During emulator testing, we switched the build to **x86 only** (see `android/app/build.gradle.kts` `abiFilters`) to avoid having to keep the arm64-v8a / x86_64 pydantic-core wheels current. Before shipping, restore all three ABIs:
|
||||
|
||||
- [x] Rebuild `pydantic-core` wheels for all three ABIs with the current SOABI + libpython linking settings (`android/build-scripts/build-pydantic-core.sh` — now supports `arm64`, `x86_64`, `x86` args; defaults to all three).
|
||||
- [x] Verify wheels: all three now list `libpython3.11.so` in `NEEDED` (`llvm-readelf -d`), automated in the build script.
|
||||
- [x] Restored `abiFilters += listOf("arm64-v8a", "x86_64", "x86")` in `build.gradle.kts`. Multi-ABI debug APK builds cleanly (~99 MB).
|
||||
- [ ] Re-test on real ARM64 Android TV hardware (still pending — only emulator-verified build).
|
||||
|
||||
Build cache + scripts live in `android/build-scripts/` and `android/.build-cache/` (junction host + sysconfigdata for each ABI).
|
||||
|
||||
## Android CI Pipeline
|
||||
|
||||
Build the Android APK automatically on push/tag.
|
||||
|
||||
- [x] Generate Gradle wrapper (`gradlew`) and commit it
|
||||
- [x] Create CI workflow (`.gitea/workflows/build-android.yml`)
|
||||
- JDK 17 + Android SDK + NDK setup
|
||||
- Python 3.11 for Chaquopy build
|
||||
- Recreate the directory junction via `ln -s` on Linux CI
|
||||
- `./gradlew assembleDebug` on master push, `assembleRelease` on `v*` tags (if signing secrets set)
|
||||
- Uploads APK as CI artifact; attaches to Gitea release on tag push
|
||||
- [x] Commit pre-built pydantic-core wheels to `android/wheels/` (arm64, x86, x86_64)
|
||||
- [x] APK signing for release builds — conditional signing config reads keystore from env vars (`ANDROID_KEYSTORE_PATH/_PASSWORD/_ALIAS/_KEY_PASSWORD`), falls back to debug signing locally
|
||||
- [ ] Provision a real keystore and add the four CI secrets:
|
||||
- `ANDROID_KEYSTORE_BASE64` (base64-encoded .jks)
|
||||
- `ANDROID_KEYSTORE_PASSWORD`
|
||||
- `ANDROID_KEY_ALIAS`
|
||||
- `ANDROID_KEY_PASSWORD`
|
||||
- [ ] Add `LedGrab-{tag}-android-release.apk` row to the release description table in `.gitea/workflows/release.yml` → `create-release` job
|
||||
- [ ] Verify the CI workflow passes end-to-end with the now-restored multi-ABI build (larger APK, longer Android build step)
|
||||
|
||||
## Android Root Capture (No Permission Dialog, No System Indicator)
|
||||
|
||||
MediaProjection shows a mandatory system overlay/indicator while capturing — unavoidable on stock Android. Many cheap Android TV boxes ship pre-rooted, so an alternative root-only path gives much better UX.
|
||||
|
||||
- [x] Root detection — `Root.kt` checks common `su` binary paths and, on demand, runs `su -c id` to actually prove UID 0. First call triggers Magisk's grant dialog; grant is cached per session. Exposed to Python via Chaquopy.
|
||||
- [x] `RootScreenrecord.kt` — spawns `su -c screenrecord --output-format=h264 --size=WxH -`, feeds the H.264 stdout through a MediaCodec decoder whose output Surface is wired into an ImageReader (RGBA_8888, row-stride-aware). Decoded frames reach the Python pipeline via `PythonBridge.pushRootFrame`.
|
||||
- [x] Python-side `RootScreenrecordEngine` (`core/capture_engines/root_screenrecord_engine.py`) mirrors `MediaProjectionEngine` with `ENGINE_PRIORITY=110` (> MediaProjection's 100) so the factory picks it automatically when available.
|
||||
- [x] `MainActivity` tries `Root.requestGrant()` before launching the MediaProjection consent flow — on rooted devices the consent dialog is skipped entirely. `CaptureService` has a `createRootIntent()` entry point that bypasses the MediaProjection path.
|
||||
- [x] Fallback: if `Root.requestGrant()` returns false (no root, user denied, or `su` timeout) the existing MediaProjection flow runs unchanged.
|
||||
- [ ] Real-hardware test pending — need to verify on the user's Magisk'd TV box that: (1) grant dialog appears once, (2) frames actually flow through MediaCodec without the Android 14 capture indicator showing, (3) stop/start cycle terminates the `su` process cleanly.
|
||||
- [WONTDO] `SurfaceControl.screenshot()` via reflection — renamed/moved across API 28/29/30/33, hidden-API blocklist varies by release, even rooted apps hit it; days of maintenance for a marginal latency win over the screenrecord path. Not worth it.
|
||||
- [WONTDO] `adb screencap` fallback — full-PNG-per-frame pipeline is slower than MediaProjection, no value as a last resort.
|
||||
|
||||
Known projects using the screenrecord approach for reference: scrcpy (over ADB), scrcpy-hidden-api, shizuku.
|
||||
|
||||
## Android Autostart on Boot
|
||||
|
||||
Boot-time, zero-interaction startup so LedGrab always has display capture and control on rooted TV boxes.
|
||||
|
||||
- [x] Manifest: declare `RECEIVE_BOOT_COMPLETED`, `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`, `WAKE_LOCK`; register `.BootReceiver` for `BOOT_COMPLETED` / `LOCKED_BOOT_COMPLETED` / `MY_PACKAGE_REPLACED`.
|
||||
- [x] `BootReceiver.kt` — gated by `AutostartPrefs` + `Root.looksRooted()`; dispatches `CaptureService.createRootIntent()` via `ContextCompat.startForegroundService`. Unrooted devices are a no-op because MediaProjection consent cannot be bypassed silently.
|
||||
- [x] `AutostartPrefs.kt` — thin SharedPreferences wrapper, defaults to enabled. Shown as a CheckBox on the stopped panel; greyed out on unrooted devices.
|
||||
- [x] `CaptureService` returns `START_REDELIVER_INTENT` for root-mode intents so the OS can cleanly restart the service after being killed (token-free path). MediaProjection-mode keeps `START_NOT_STICKY` — restart is pointless with a dead consent token.
|
||||
- [x] `isRunning` race: moved assignment to after `startForeground` succeeds, resets on exception; `onStartCommand` wraps `startForeground` in try/catch and stops the service cleanly if the FG transition fails.
|
||||
- [x] Root-capture watchdog: coroutine on `serviceScope` checks `RootScreenrecord.framesDelivered` every 5s after a 5s grace. Respawns the pipeline (reusing the existing Python bridge) on stall, caps at 3 consecutive restarts before giving up.
|
||||
- [x] `RootScreenrecord.framesDelivered` exposed as a property backed by `AtomicInteger` (was `@Volatile var framesDelivered = 0` with non-atomic `+= 1`).
|
||||
- [x] `ScreenCapture` accepts `onProjectionStopped` lambda — `MediaProjection.Callback.onStop` now tears the whole service down instead of leaving a stale FG notification.
|
||||
- [x] `MainActivity` wires the autostart toggle to `AutostartPrefs`; enabling it prompts `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` so Doze doesn't kill the FG service on phones.
|
||||
- [x] `versionCode` derived from `git rev-list --count HEAD` (or `ANDROID_VERSION_CODE` env var in CI). Was stuck at 1 — sideload updates were silently refusing to install.
|
||||
- [ ] Real-hardware test pending — need to verify on the user's Magisk'd TV box that: (1) boot-time autostart dispatches the service without UI, (2) capture indicator still absent under root mode post-reboot, (3) watchdog respawns the pipeline when `screenrecord` is externally killed, (4) sideload upgrade installs cleanly after the versionCode bump.
|
||||
- [ ] Optional follow-up: "kiosk" mode — add `<category android:name="android.intent.category.HOME" />` to `MainActivity` so power users can set LedGrab as the default TV launcher for truly always-running behavior.
|
||||
|
||||
## Android USB Serial Support
|
||||
|
||||
Drive USB LED controllers (APA102, WS2812) connected directly to the Android TV box via USB-to-serial adapters.
|
||||
|
||||
- [x] Added `com.github.mik3y:usb-serial-for-android:3.8.1` (via JitPack) to `android/app/build.gradle.kts`.
|
||||
- [x] Kotlin `UsbSerialBridge` singleton (`android/app/src/main/java/com/ledgrab/android/UsbSerialBridge.kt`) — exposes `listDevices()`, `open(vid, pid, serial, baud)`, `write(handle, ByteArray)`, `close(handle)`. Permission request fires automatically from `open()` when the user hasn't granted access yet. Handles are opaque integers, port map is synchronized, so Python threads can share one bridge.
|
||||
- [x] Python `AndroidSerialTransport` in `server/src/ledgrab/core/devices/android_serial_transport.py` drives the bridge through Chaquopy. `SerialTransport` Protocol + `PySerialTransport` + `list_serial_ports()` factory live in `serial_transport.py`; `AdalightClient` and `SerialDeviceProvider` now go through the abstraction instead of importing `pyserial` directly.
|
||||
- [x] URL scheme extended: `usb:VID:PID[:serial][@baud]` on Android alongside the existing `COM3[:baud]` / `/dev/ttyUSB0[:baud]` desktop paths.
|
||||
- [x] App initializes the bridge on startup (`LedGrabApp.onCreate` → `UsbSerialBridge.init(this)`); manifest declares `uses-feature android.hardware.usb.host`.
|
||||
- [ ] Real-device test pending — no USB-serial hardware on dev machine. Need to verify on a TV box with CH340, CP2102, or FTDI adapter.
|
||||
- [ ] Document supported USB LED controllers in README (once real-device test passes).
|
||||
- [ ] Optional: auto-launch the app when a known USB-serial adapter is plugged in (intent-filter on `USB_DEVICE_ATTACHED` + `res/xml/device_filter.xml`). Skipped in v1 — users can just open LedGrab and hit "Discover".
|
||||
- [x] ESP-NOW client (`espnow_client.py` / `espnow_provider.py`) now routes through `SerialTransport` — `open_transport()` for the gateway serial link, `list_serial_ports()` + `port_exists()` for discovery/validation. Works transparently with `usb:VID:PID` URLs on Android. (Gateway protocol is write-only, so no `read()` extension was needed after all.)
|
||||
|
||||
## Performance Metrics Abstraction
|
||||
|
||||
- [x] `MetricsProvider` protocol + dataclass DTOs (`MemorySnapshot`, `ProcessSnapshot`) live in `server/src/ledgrab/utils/metrics/types.py`. Each provider has its own module: `psutil_provider.py`, `null_provider.py`, `android_provider.py`.
|
||||
- [x] Factory `get_metrics_provider()` in `utils/metrics/__init__.py` selects Android → psutil → Null. `psutil` import is now confined to one place.
|
||||
- [x] `api/routes/system.py` and `core/processing/metrics_history.py` use the provider; no more `if psutil is not None` guards in the hot paths.
|
||||
- [x] Android `/proc`-backed provider implemented (`/proc/stat`, `/proc/meminfo`, `/proc/self/stat`, `/proc/self/status`). Carries previous-sample state for delta-based CPU%; degrades to zeros if any `/proc` file is locked down. 12 unit tests cover both desktop and Android paths.
|
||||
|
||||
## Android Performance Metrics — Future Enhancements
|
||||
|
||||
Beyond the `/proc`-based AndroidMetricsProvider that's now in place:
|
||||
|
||||
- [x] Device battery + thermal-zone readings (`/sys/class/power_supply/battery/{capacity,temp}`, `/sys/class/thermal/thermal_zone*/temp` filtered by zone type). Surfaced through `MetricsProvider.thermals()`, `PerformanceResponse.{cpu_temp_c,battery_percent,battery_temp_c}`, the metrics-history snapshot, and a new dashboard temperature chart that hides itself when the backend reports null. GPU card now hides (no "unavailable" placeholder) when no GPU is present.
|
||||
- [WONTDO] Optional: app-specific memory via `Debug.getMemoryInfo()` through a Kotlin → Python Chaquopy bridge (more accurate than `VmRSS` for split-app-process accounting)
|
||||
- [WONTDO] Optional: GPU usage via `/sys/class/kgsl/kgsl-3d0/gpubusy` on Adreno, Mali-specific paths for Mali GPUs
|
||||
|
||||
## Refactor: Per-Provider Device Configs
|
||||
|
||||
Replace flat `DeviceInfo` + `**kwargs` provider contract with a discriminated union of typed per-provider config dataclasses. Full plan: [docs/plans/device-typed-configs.md](docs/plans/device-typed-configs.md).
|
||||
|
||||
- [x] Phase 1 — `DeviceConfig` hierarchy + `Device.to_config()` (non-breaking, additive only)
|
||||
- [x] Phases 2+3 — narrow `LEDDeviceProvider.create_client` to typed configs; migrate 3 call sites; delete `DeviceInfo` + `_get_device_info` + `_DEVICE_FIELD_DEFAULTS` (single PR)
|
||||
- [x] Phase 4 — migrate `tests/test_group_device.py` to `GroupConfig`/`ProviderDeps`; remove legacy `GroupLEDClient` init path; 47-test config suite with 100% coverage on `device_config.py`
|
||||
- [ ] Phase 5 (separate PR, optional) — Pydantic v2 discriminated union in `api/schemas/devices.py`; scope frontend POST/PATCH payloads by `device_type`
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
.DS_Store
|
||||
/build
|
||||
/app/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
|
||||
# Chaquopy build cache
|
||||
.build-cache/
|
||||
|
||||
# Python source junction (points at ../server/src/ledgrab — do not commit)
|
||||
/app/src/main/python/ledgrab
|
||||
|
||||
# Signing keystore decoded from CI secrets
|
||||
/keystore/
|
||||
@@ -0,0 +1,183 @@
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("com.chaquo.python")
|
||||
}
|
||||
|
||||
// Derive versionCode from git commit count so sideload/Play upgrades
|
||||
// always see a strictly-greater value without anyone remembering to
|
||||
// bump by hand. ANDROID_VERSION_CODE env var wins for CI pipelines
|
||||
// that prefer explicit values; fallback is `1` when neither is
|
||||
// available (e.g. building from a tarball with no .git).
|
||||
val ledgrabVersionCode: Int = run {
|
||||
System.getenv("ANDROID_VERSION_CODE")?.toIntOrNull()?.let { return@run it }
|
||||
try {
|
||||
val process = ProcessBuilder("git", "rev-list", "--count", "HEAD")
|
||||
.directory(rootDir)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
if (process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0) {
|
||||
process.inputStream.bufferedReader().readText().trim().toIntOrNull() ?: 1
|
||||
} else {
|
||||
1
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.ledgrab.android"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.ledgrab.android"
|
||||
minSdk = 24 // Android 7.0 — covers nearly all TV boxes
|
||||
targetSdk = 34
|
||||
// Derived from git commit count (or ANDROID_VERSION_CODE env var
|
||||
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
|
||||
// sideload updates silently refused to install.
|
||||
versionCode = ledgrabVersionCode
|
||||
versionName = "0.6.0"
|
||||
|
||||
ndk {
|
||||
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
|
||||
// emulators), x86 (legacy emulators). Wheels in android/wheels/
|
||||
// must be kept in sync — see build-scripts/build-pydantic-core.sh.
|
||||
abiFilters += listOf("arm64-v8a", "x86_64", "x86")
|
||||
}
|
||||
}
|
||||
|
||||
// Per-ABI APK splits — reduces download size by ~60% vs universal APK.
|
||||
// Each split contains only one native ABI's shared libraries + wheels.
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
reset()
|
||||
include("arm64-v8a", "x86_64", "x86")
|
||||
isUniversalApk = true // also produce a fat APK for sideloading
|
||||
}
|
||||
}
|
||||
|
||||
// Signing config from env vars (CI) — only registered when all four are set.
|
||||
// Local release builds fall back to the debug signing config.
|
||||
val ciKeystorePath = System.getenv("ANDROID_KEYSTORE_PATH")
|
||||
val ciKeystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||
val ciKeyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
||||
val ciKeyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
||||
val hasCiSigning = listOf(ciKeystorePath, ciKeystorePassword, ciKeyAlias, ciKeyPassword)
|
||||
.all { !it.isNullOrBlank() } && file(ciKeystorePath!!).exists()
|
||||
|
||||
signingConfigs {
|
||||
if (hasCiSigning) {
|
||||
create("release") {
|
||||
storeFile = file(ciKeystorePath!!)
|
||||
storePassword = ciKeystorePassword
|
||||
keyAlias = ciKeyAlias
|
||||
keyPassword = ciKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO(minify): keep R8 disabled until Chaquopy reflection is
|
||||
// verified end-to-end. Chaquopy resolves Kotlin classes & static
|
||||
// methods (PythonBridge, UsbSerialBridge, Root) by name from
|
||||
// Python via PyObject — silent stripping breaks the app at
|
||||
// runtime, after release. proguard-rules.pro contains keep
|
||||
// rules covering the known entry points, but until we have
|
||||
// a release smoke test that exercises every PyObject path we
|
||||
// do NOT ship a minified release.
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
signingConfig = if (hasCiSigning) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
chaquopy {
|
||||
defaultConfig {
|
||||
version = "3.11"
|
||||
|
||||
pip {
|
||||
// Pre-built wheels directory (for pydantic-core etc.) as an
|
||||
// absolute file:// URI with forward slashes. Pip requires
|
||||
// exactly three slashes after `file:` for an empty-host local
|
||||
// path; the path-normalization differs by platform:
|
||||
// Windows: absolute path is "C:/..." → prefix "file:///"
|
||||
// Linux: absolute path is "/..." → prefix "file://"
|
||||
// Using a fixed "file:///" prefix on both made CI produce
|
||||
// "file:////workspace/…", which pip rejects as non-local.
|
||||
val wheelsPath = rootDir.absolutePath.replace("\\", "/")
|
||||
val wheelsUrlPrefix = if (wheelsPath.startsWith("/")) "file://" else "file:///"
|
||||
options("--find-links", "$wheelsUrlPrefix$wheelsPath/wheels/")
|
||||
|
||||
// ── Android-compatible dependencies ─────────────────
|
||||
// Listed explicitly because pyproject.toml includes
|
||||
// desktop-only packages with no Android wheels.
|
||||
// See CLAUDE.md "Android Dependency Sync" for policy.
|
||||
install("fastapi")
|
||||
install("uvicorn") // without [standard] — no uvloop/httptools
|
||||
install("httpx")
|
||||
install("numpy")
|
||||
install("pydantic") // needs pydantic-core wheel in wheels/
|
||||
install("pydantic-settings")
|
||||
install("PyYAML")
|
||||
install("structlog")
|
||||
install("python-json-logger")
|
||||
install("python-dateutil")
|
||||
install("python-multipart")
|
||||
install("jinja2")
|
||||
install("zeroconf")
|
||||
install("aiomqtt")
|
||||
install("openrgb-python")
|
||||
// opencv-python-headless: no cp311 Android wheel on Chaquopy.
|
||||
// LedGrab's cv2 usage is guarded with try/except ImportError
|
||||
// and falls back to numpy/Pillow alternatives on Android.
|
||||
install("Pillow")
|
||||
install("websockets")
|
||||
install("cryptography") // AES-GCM secret-box for HA/MQTT credentials
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LedGrab Python source is included via a directory junction:
|
||||
// android/app/src/main/python/ledgrab -> server/src/ledgrab
|
||||
// This is the standard Chaquopy way to include local Python packages.
|
||||
// Create the junction (run from repo root, no admin needed):
|
||||
// cmd /c "mklink /J android\app\src\main\python\ledgrab server\src\ledgrab"
|
||||
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.leanback:leanback:1.0.0")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-service:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||
// QR code generation for displaying server URL on TV
|
||||
implementation("com.google.zxing:core:3.5.3")
|
||||
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
|
||||
// driving Adalight/AmbiLED controllers plugged into Android TV boxes.
|
||||
implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
|
||||
}
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
# LedGrab ProGuard / R8 rules.
|
||||
#
|
||||
# IMPORTANT: Chaquopy resolves Java/Kotlin classes and static methods by
|
||||
# name from Python (e.g. UsbSerialBridge.INSTANCE.listDevices()) via
|
||||
# reflection. Anything reachable through PyObject must be kept by name
|
||||
# or the release build will throw NoSuchMethod / ClassNotFound at
|
||||
# runtime — silently, only on the user's device.
|
||||
#
|
||||
# Keep ALL of com.ledgrab.android.* members for safety. The app is
|
||||
# small enough that the size win from stripping these isn't worth the
|
||||
# fragility.
|
||||
-keep class com.ledgrab.android.** { *; }
|
||||
|
||||
# Chaquopy runtime itself.
|
||||
-keep class com.chaquo.python.** { *; }
|
||||
-dontwarn com.chaquo.python.**
|
||||
|
||||
# usb-serial-for-android — driver classes are loaded via the prober's
|
||||
# default device-id list, which uses reflection in some chip drivers.
|
||||
-keep class com.hoho.android.usbserial.driver.** { *; }
|
||||
-dontwarn com.hoho.android.usbserial.**
|
||||
|
||||
# Kotlin coroutines — keep the debug agent off and the metadata intact.
|
||||
-dontwarn kotlinx.coroutines.**
|
||||
|
||||
# Standard Android best-practice keeps.
|
||||
-keepattributes Signature, InnerClasses, EnclosingMethod, *Annotation*
|
||||
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- BLE scanning and connecting — API ≥31 uses granular permissions;
|
||||
older releases need BLUETOOTH + ACCESS_FINE_LOCATION for scanning.
|
||||
neverForLocation avoids the location permission dialog on API 31+. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<!-- BLE hardware — required=false so non-BT boxes still install. -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.bluetooth_le"
|
||||
android:required="false" />
|
||||
|
||||
<!-- Network access for WLED HTTP/UDP, web UI, MQTT -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<!-- MediaProjection requires a foreground service -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
|
||||
<!-- POST_NOTIFICATIONS for Android 13+ foreground service notification -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Autostart on boot — BootReceiver spawns CaptureService in root
|
||||
mode so capture resumes without the user touching the remote. -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<!-- Exempt from Doze/App Standby so the FG service isn't killed
|
||||
overnight on phones; essentially a no-op on TV boxes. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<!-- Optional wake lock for sustained-performance boxes that aggressively
|
||||
sleep the CPU with the display off. -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- Android TV declarations -->
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="true" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
|
||||
<!-- USB host — for USB-to-TTL adapters driving Adalight/AmbiLED
|
||||
controllers. required=false so phones without USB host still install. -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.usb.host"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".LedGrabApp"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:banner="@drawable/ic_launcher"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@style/Theme.LedGrab">
|
||||
|
||||
<!-- TV launcher activity -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Foreground service for screen capture + Python server -->
|
||||
<service
|
||||
android:name=".CaptureService"
|
||||
android:foregroundServiceType="mediaProjection"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Autostart — fires on device boot (and package replace).
|
||||
On rooted devices, launches CaptureService directly so capture
|
||||
resumes without the user tapping Start. Unrooted devices are
|
||||
no-op because MediaProjection consent cannot be bypassed. -->
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:exported="true"
|
||||
android:enabled="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* Thin SharedPreferences wrapper for the boot-autostart toggle.
|
||||
*
|
||||
* Default is `true`: [BootReceiver] still bails out when the device
|
||||
* isn't rooted, so a true default is safe — it only takes effect on
|
||||
* boxes where we can actually honor it silently.
|
||||
*/
|
||||
class AutostartPrefs(context: Context) {
|
||||
|
||||
private val prefs = context.applicationContext
|
||||
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
var isEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED)
|
||||
set(value) { prefs.edit().putBoolean(KEY_ENABLED, value).apply() }
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "ledgrab_autostart"
|
||||
private const val KEY_ENABLED = "autostart_enabled"
|
||||
private const val DEFAULT_ENABLED = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import java.util.Collections
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
|
||||
/**
|
||||
* Android BLE bridge exposed to the Python server via Chaquopy.
|
||||
*
|
||||
* Wraps the Android BluetoothGatt / BluetoothLeScanner APIs into
|
||||
* synchronous, blocking calls that can be safely invoked from
|
||||
* a Python thread (Chaquopy proxy threads are real OS threads).
|
||||
*
|
||||
* All GATT callbacks run on a private [HandlerThread] so they don't
|
||||
* block the main looper. [runBlocking] is used to bridge callback
|
||||
* completions back to the calling Python thread.
|
||||
*
|
||||
* Python callers access the singleton via
|
||||
* `BleBridge.INSTANCE.scan()` etc. — see
|
||||
* `server/src/ledgrab/core/devices/android_ble_transport.py`.
|
||||
*/
|
||||
object BleBridge {
|
||||
private const val TAG = "BleBridge"
|
||||
private const val CONNECT_TIMEOUT_MS = 18_000L // connect + service discovery
|
||||
private const val WRITE_TIMEOUT_MS = 5_000L
|
||||
|
||||
@Volatile private var appContext: Context? = null
|
||||
|
||||
private val handleSeq = AtomicInteger(1)
|
||||
|
||||
// Dedicated looper thread so BLE callbacks don't land on the main thread.
|
||||
private val bleHandlerThread = HandlerThread("LedGrab-BLE").also { it.start() }
|
||||
private val bleHandler = Handler(bleHandlerThread.looper)
|
||||
|
||||
private data class GattHandle(
|
||||
val gatt: BluetoothGatt,
|
||||
val writeChar: BluetoothGattCharacteristic,
|
||||
)
|
||||
|
||||
private val handles = ConcurrentHashMap<Int, GattHandle>()
|
||||
|
||||
// Write completion futures, keyed by handle. Only populated for
|
||||
// WRITE_TYPE_DEFAULT (with-response) writes.
|
||||
private val pendingWrites = ConcurrentHashMap<Int, CompletableDeferred<Boolean>>()
|
||||
|
||||
/** Called once from [LedGrabApp.onCreate] to bind the application context. */
|
||||
@JvmStatic
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
private fun ctx(): Context =
|
||||
appContext ?: error("BleBridge.init() not called — app context unavailable")
|
||||
|
||||
private fun adapter() =
|
||||
ctx().getSystemService(BluetoothManager::class.java)?.adapter
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan for BLE peripherals for [timeoutMs] milliseconds.
|
||||
*
|
||||
* Returns a list of `"address|name|rssi"` strings. Addresses are
|
||||
* deduplicated — only the last-seen RSSI for each address is kept.
|
||||
* Returns an empty list if Bluetooth is off or the permission is denied.
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun scan(timeoutMs: Long = 4_000L): List<String> {
|
||||
val adapter = adapter() ?: return emptyList()
|
||||
if (!adapter.isEnabled) return emptyList()
|
||||
val scanner = try { adapter.bluetoothLeScanner } catch (_: SecurityException) { null }
|
||||
?: return emptyList()
|
||||
|
||||
val seen = Collections.synchronizedMap(LinkedHashMap<String, String>())
|
||||
val callback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val address = result.device.address ?: return
|
||||
val name = result.scanRecord?.deviceName ?: result.device.name ?: ""
|
||||
seen[address] = "$address|$name|${result.rssi}"
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
Log.w(TAG, "BLE scan failed with error $errorCode")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
bleHandler.post { scanner.startScan(callback) }
|
||||
Thread.sleep(timeoutMs)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
} finally {
|
||||
try { bleHandler.post { scanner.stopScan(callback) } } catch (_: SecurityException) {}
|
||||
}
|
||||
return seen.values.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the BLE peripheral at [address] and locate the GATT
|
||||
* characteristic identified by [writeCharUuid] across all services.
|
||||
*
|
||||
* Blocks until connected + services discovered, or returns -1 on failure.
|
||||
* The returned integer is an opaque handle passed to [write]/[disconnect].
|
||||
*/
|
||||
@JvmStatic
|
||||
fun connect(address: String, writeCharUuid: String): Int {
|
||||
val adapter = adapter() ?: return -1
|
||||
val device = try { adapter.getRemoteDevice(address) } catch (e: Exception) {
|
||||
Log.e(TAG, "Invalid BLE address '$address': ${e.message}")
|
||||
return -1
|
||||
}
|
||||
|
||||
val readyDeferred = CompletableDeferred<Boolean>()
|
||||
|
||||
val callback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
when {
|
||||
newState == BluetoothProfile.STATE_CONNECTED
|
||||
&& status == BluetoothGatt.GATT_SUCCESS -> {
|
||||
Log.d(TAG, "GATT connected to $address, discovering services")
|
||||
gatt.discoverServices()
|
||||
}
|
||||
newState == BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
Log.w(TAG, "GATT disconnected from $address (status=$status)")
|
||||
readyDeferred.complete(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||
readyDeferred.complete(status == BluetoothGatt.GATT_SUCCESS)
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: Int,
|
||||
) {
|
||||
val h = handles.entries.firstOrNull { it.value.gatt === gatt }?.key ?: return
|
||||
pendingWrites.remove(h)?.complete(status == BluetoothGatt.GATT_SUCCESS)
|
||||
}
|
||||
}
|
||||
|
||||
val gatt: BluetoothGatt = try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
device.connectGatt(
|
||||
ctx(), false, callback,
|
||||
android.bluetooth.BluetoothDevice.TRANSPORT_LE,
|
||||
android.bluetooth.BluetoothDevice.PHY_LE_1M_MASK,
|
||||
bleHandler,
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
device.connectGatt(
|
||||
ctx(), false, callback,
|
||||
android.bluetooth.BluetoothDevice.TRANSPORT_LE,
|
||||
)
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "BLUETOOTH_CONNECT permission denied for $address", e)
|
||||
return -1
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "connectGatt failed for $address", e)
|
||||
return -1
|
||||
}
|
||||
|
||||
val ready = try {
|
||||
runBlocking { withTimeout(CONNECT_TIMEOUT_MS) { readyDeferred.await() } }
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
Log.e(TAG, "BLE connect+discovery timed out for $address")
|
||||
runCatching { gatt.close() }
|
||||
return -1
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
runCatching { gatt.close() }
|
||||
return -1
|
||||
}
|
||||
|
||||
val charUuid = try { UUID.fromString(writeCharUuid) } catch (e: Exception) {
|
||||
Log.e(TAG, "Invalid characteristic UUID '$writeCharUuid'")
|
||||
gatt.disconnect(); gatt.close()
|
||||
return -1
|
||||
}
|
||||
val writeChar = gatt.services.flatMap { it.characteristics }
|
||||
.firstOrNull { it.uuid == charUuid }
|
||||
|
||||
if (writeChar == null) {
|
||||
Log.e(TAG, "Characteristic $writeCharUuid not found on $address")
|
||||
gatt.disconnect(); gatt.close()
|
||||
return -1
|
||||
}
|
||||
|
||||
val handle = handleSeq.getAndIncrement()
|
||||
handles[handle] = GattHandle(gatt, writeChar)
|
||||
Log.i(TAG, "BLE connected: address=$address char=$writeCharUuid handle=$handle")
|
||||
return handle
|
||||
}
|
||||
|
||||
/**
|
||||
* Write [data] to the characteristic associated with [handle].
|
||||
*
|
||||
* [withResponse] controls the GATT write type:
|
||||
* - `true` → Write Request (waits for device ACK, slower but reliable)
|
||||
* - `false` → Write Command (fire-and-forget, faster, used by SP110E/Triones/Zengge)
|
||||
*
|
||||
* Returns `true` on success, `false` on any error.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun write(handle: Int, data: ByteArray, withResponse: Boolean): Boolean {
|
||||
val entry = handles[handle] ?: return false
|
||||
val gatt = entry.gatt
|
||||
val char = entry.writeChar
|
||||
val writeType = if (withResponse)
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
else
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
|
||||
return if (withResponse) {
|
||||
val deferred = CompletableDeferred<Boolean>()
|
||||
pendingWrites[handle] = deferred
|
||||
|
||||
val initiated = gattWrite(gatt, char, data, writeType)
|
||||
if (!initiated) {
|
||||
pendingWrites.remove(handle)
|
||||
return false
|
||||
}
|
||||
try {
|
||||
runBlocking { withTimeout(WRITE_TIMEOUT_MS) { deferred.await() } }
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
pendingWrites.remove(handle)
|
||||
Log.w(TAG, "BLE write-with-response timed out on handle $handle")
|
||||
false
|
||||
}
|
||||
} else {
|
||||
gattWrite(gatt, char, data, writeType)
|
||||
}
|
||||
}
|
||||
|
||||
/** Disconnect and close the GATT connection for [handle]. */
|
||||
@JvmStatic
|
||||
fun disconnect(handle: Int) {
|
||||
val entry = handles.remove(handle) ?: return
|
||||
pendingWrites.remove(handle)?.complete(false)
|
||||
runCatching {
|
||||
entry.gatt.disconnect()
|
||||
entry.gatt.close()
|
||||
}.onFailure { Log.w(TAG, "BLE disconnect error for handle $handle: ${it.message}") }
|
||||
Log.i(TAG, "BLE disconnected handle=$handle")
|
||||
}
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private fun gattWrite(
|
||||
gatt: BluetoothGatt,
|
||||
char: BluetoothGattCharacteristic,
|
||||
data: ByteArray,
|
||||
writeType: Int,
|
||||
): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
gatt.writeCharacteristic(char, data, writeType) ==
|
||||
android.bluetooth.BluetoothStatusCodes.SUCCESS
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
char.writeType = writeType
|
||||
@Suppress("DEPRECATION")
|
||||
char.value = data
|
||||
@Suppress("DEPRECATION")
|
||||
gatt.writeCharacteristic(char)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* Starts the LedGrab capture service automatically on device boot and
|
||||
* after the app is updated.
|
||||
*
|
||||
* Autostart is best-effort and deliberately limited:
|
||||
* - Requires root. MediaProjection consent cannot be granted silently,
|
||||
* so we can't resume capture on unrooted boxes without the user
|
||||
* walking up to the TV and tapping Start.
|
||||
* - Gated behind [AutostartPrefs] so the user can opt out.
|
||||
*
|
||||
* Receivers declared with BOOT_COMPLETED must be fast — we hand off to
|
||||
* a foreground service within milliseconds and let the service do the
|
||||
* heavy lifting (Chaquopy bootstrap, pipeline setup).
|
||||
*/
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val action = intent.action
|
||||
Log.i(TAG, "Boot event: $action")
|
||||
|
||||
if (action != Intent.ACTION_BOOT_COMPLETED
|
||||
&& action != Intent.ACTION_LOCKED_BOOT_COMPLETED
|
||||
&& action != Intent.ACTION_MY_PACKAGE_REPLACED
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!AutostartPrefs(context).isEnabled) {
|
||||
Log.i(TAG, "Autostart disabled — skipping")
|
||||
return
|
||||
}
|
||||
|
||||
// Cheap check first (no process spawn). The real `su -c id` probe
|
||||
// would block and may not complete before the OS reclaims us.
|
||||
if (!Root.looksRooted()) {
|
||||
Log.i(TAG, "Device not rooted — cannot autostart without MediaProjection consent")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
CaptureService.createRootIntent(context),
|
||||
)
|
||||
Log.i(TAG, "LedGrab capture service dispatched on boot")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start service on boot", e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BootReceiver"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.projection.MediaProjection
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Foreground service that runs the Python LedGrab server and captures
|
||||
* the screen via MediaProjection.
|
||||
*/
|
||||
class CaptureService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CaptureService"
|
||||
private const val CHANNEL_ID = "ledgrab_capture"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val EXTRA_RESULT_CODE = "result_code"
|
||||
private const val EXTRA_RESULT_DATA = "result_data"
|
||||
private const val EXTRA_USE_ROOT = "use_root"
|
||||
private const val SERVER_PORT = 8080
|
||||
private const val CAPTURE_WIDTH = 480
|
||||
private const val CAPTURE_HEIGHT = 270
|
||||
private const val CAPTURE_FPS = 30
|
||||
|
||||
// Watchdog: if no new root-capture frames arrive inside this
|
||||
// window the pipeline is considered stalled and respawned.
|
||||
// Grace gives screenrecord time to produce its first I-frame.
|
||||
private const val WATCHDOG_GRACE_MS = 5_000L
|
||||
private const val WATCHDOG_CHECK_MS = 5_000L
|
||||
private const val WATCHDOG_MAX_RESTARTS = 3
|
||||
|
||||
/** True while the service is alive. Survives activity recreation. */
|
||||
@Volatile
|
||||
@JvmStatic
|
||||
var isRunning: Boolean = false
|
||||
private set
|
||||
|
||||
fun createIntent(
|
||||
context: Context,
|
||||
resultCode: Int,
|
||||
resultData: Intent,
|
||||
): Intent {
|
||||
return Intent(context, CaptureService::class.java).apply {
|
||||
putExtra(EXTRA_RESULT_CODE, resultCode)
|
||||
putExtra(EXTRA_RESULT_DATA, resultData)
|
||||
}
|
||||
}
|
||||
|
||||
/** Root-mode intent — no MediaProjection consent data required. */
|
||||
fun createRootIntent(context: Context): Intent {
|
||||
return Intent(context, CaptureService::class.java).apply {
|
||||
putExtra(EXTRA_USE_ROOT, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bridge: PythonBridge? = null
|
||||
private var screenCapture: ScreenCapture? = null
|
||||
private var rootCapture: RootScreenrecord? = null
|
||||
private var mediaProjection: MediaProjection? = null
|
||||
|
||||
// Service-scoped coroutine scope for the root-capture watchdog.
|
||||
// SupervisorJob so a watchdog crash doesn't tear down other children.
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private var watchdogJob: Job? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// CRITICAL: startForeground must be called IMMEDIATELY —
|
||||
// before any other work, especially before getMediaProjection().
|
||||
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
|
||||
val url = "http://$localIp:$SERVER_PORT"
|
||||
try {
|
||||
startForeground(NOTIFICATION_ID, buildNotification(url))
|
||||
} catch (e: Exception) {
|
||||
// Most common cause: missing foregroundServiceType permission
|
||||
// or denied POST_NOTIFICATIONS on API 34+.
|
||||
Log.e(TAG, "startForeground failed — service cannot run", e)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
// Only flip the public flag once the FG transition has succeeded,
|
||||
// otherwise `isRunning=true` sticks forever when startForeground throws.
|
||||
isRunning = true
|
||||
|
||||
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
|
||||
|
||||
if (intent == null && !useRoot) {
|
||||
// MediaProjection mode can't recover from a redelivery —
|
||||
// the consent token in the original intent is single-use.
|
||||
Log.w(TAG, "Service restarted without intent (MediaProjection mode) — stopping")
|
||||
isRunning = false
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
try {
|
||||
if (useRoot) {
|
||||
startRootCapture(url)
|
||||
} else {
|
||||
startMediaProjectionCapture(intent!!, url)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start capture", e)
|
||||
isRunning = false
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
// Root mode can be cleanly restarted from the original intent
|
||||
// because it carries no perishable consent token. MediaProjection
|
||||
// mode cannot — a restart there would silently start the service
|
||||
// with a dead projection. START_NOT_STICKY there, the user has
|
||||
// to tap Start again.
|
||||
return if (useRoot) START_REDELIVER_INTENT else START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun startRootCapture(url: String) {
|
||||
val newBridge = PythonBridge(this).also { b ->
|
||||
b.configureRootCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
|
||||
b.startServer(SERVER_PORT)
|
||||
}
|
||||
bridge = newBridge
|
||||
|
||||
val pipeline = RootScreenrecord(
|
||||
bridge = newBridge,
|
||||
width = CAPTURE_WIDTH,
|
||||
height = CAPTURE_HEIGHT,
|
||||
fps = CAPTURE_FPS,
|
||||
)
|
||||
if (!pipeline.start()) {
|
||||
Log.w(TAG, "Root capture failed to launch — stopping service")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
rootCapture = pipeline
|
||||
startWatchdog()
|
||||
Log.i(TAG, "LedGrab service started (root mode) — web UI at $url")
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the active root pipeline with a fresh instance, reusing
|
||||
* the existing Python bridge (no server restart). Returns true if
|
||||
* the new pipeline launched, false otherwise.
|
||||
*/
|
||||
private fun restartRootPipeline(): Boolean {
|
||||
val currentBridge = bridge ?: return false
|
||||
val old = rootCapture
|
||||
rootCapture = null
|
||||
runCatching { old?.stop() }
|
||||
|
||||
val next = RootScreenrecord(
|
||||
bridge = currentBridge,
|
||||
width = CAPTURE_WIDTH,
|
||||
height = CAPTURE_HEIGHT,
|
||||
fps = CAPTURE_FPS,
|
||||
)
|
||||
if (!next.start()) {
|
||||
Log.e(TAG, "Root capture failed to restart")
|
||||
return false
|
||||
}
|
||||
rootCapture = next
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor the root pipeline's frame counter. If no new frames
|
||||
* arrive within one check window, respawn the pipeline. Caps at
|
||||
* [WATCHDOG_MAX_RESTARTS] consecutive failures before giving up so
|
||||
* we don't burn battery forever on a device that genuinely can't
|
||||
* produce frames.
|
||||
*/
|
||||
private fun startWatchdog() {
|
||||
watchdogJob?.cancel()
|
||||
watchdogJob = serviceScope.launch {
|
||||
delay(WATCHDOG_GRACE_MS)
|
||||
var last = rootCapture?.framesDelivered ?: 0
|
||||
var restartAttempts = 0
|
||||
while (isActive) {
|
||||
delay(WATCHDOG_CHECK_MS)
|
||||
val pipe = rootCapture ?: break
|
||||
val current = pipe.framesDelivered
|
||||
if (current == last) {
|
||||
restartAttempts += 1
|
||||
Log.w(
|
||||
TAG,
|
||||
"Root capture stalled (no new frames in ${WATCHDOG_CHECK_MS}ms); " +
|
||||
"restart attempt $restartAttempts/$WATCHDOG_MAX_RESTARTS",
|
||||
)
|
||||
if (restartAttempts > WATCHDOG_MAX_RESTARTS) {
|
||||
Log.e(TAG, "Watchdog gave up after $WATCHDOG_MAX_RESTARTS restarts")
|
||||
stopSelf()
|
||||
return@launch
|
||||
}
|
||||
if (!restartRootPipeline()) {
|
||||
stopSelf()
|
||||
return@launch
|
||||
}
|
||||
last = rootCapture?.framesDelivered ?: 0
|
||||
} else {
|
||||
// Forward progress — forgive earlier glitches.
|
||||
restartAttempts = 0
|
||||
last = current
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startMediaProjectionCapture(intent: Intent, url: String) {
|
||||
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
|
||||
@Suppress("DEPRECATION")
|
||||
val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
|
||||
} else {
|
||||
intent.getParcelableExtra(EXTRA_RESULT_DATA)
|
||||
}
|
||||
|
||||
if (resultData == null) {
|
||||
Log.e(TAG, "No MediaProjection result data")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
|
||||
val projectionManager =
|
||||
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
val projection = projectionManager.getMediaProjection(resultCode, resultData)
|
||||
if (projection == null) {
|
||||
Log.e(TAG, "Failed to create MediaProjection")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
mediaProjection = projection
|
||||
|
||||
val metrics = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val windowMetrics = (getSystemService(Context.WINDOW_SERVICE) as WindowManager)
|
||||
.currentWindowMetrics
|
||||
DisplayMetrics().apply {
|
||||
val bounds = windowMetrics.bounds
|
||||
widthPixels = bounds.width()
|
||||
heightPixels = bounds.height()
|
||||
// densityDpi is still needed for VirtualDisplay; read from resources.
|
||||
densityDpi = resources.displayMetrics.densityDpi
|
||||
}
|
||||
} else {
|
||||
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
DisplayMetrics().also { m ->
|
||||
@Suppress("DEPRECATION")
|
||||
windowManager.defaultDisplay.getRealMetrics(m)
|
||||
}
|
||||
}
|
||||
|
||||
val newBridge = PythonBridge(this).also { b ->
|
||||
b.configureCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
|
||||
b.startServer(SERVER_PORT)
|
||||
}
|
||||
bridge = newBridge
|
||||
|
||||
screenCapture = ScreenCapture(
|
||||
projection = projection,
|
||||
metrics = metrics,
|
||||
bridge = newBridge,
|
||||
targetWidth = CAPTURE_WIDTH,
|
||||
targetHeight = CAPTURE_HEIGHT,
|
||||
targetFps = CAPTURE_FPS,
|
||||
// If the user taps the system Cast/Screen-capture stop banner,
|
||||
// MediaProjection.Callback.onStop fires — tear the whole
|
||||
// service down so the notification/Python server don't linger.
|
||||
onProjectionStopped = { stopSelf() },
|
||||
).also { it.start() }
|
||||
|
||||
Log.i(TAG, "LedGrab service started (MediaProjection) — web UI at $url")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
isRunning = false
|
||||
|
||||
watchdogJob?.cancel()
|
||||
watchdogJob = null
|
||||
serviceScope.cancel()
|
||||
|
||||
screenCapture?.stop()
|
||||
screenCapture = null
|
||||
|
||||
rootCapture?.stop()
|
||||
rootCapture = null
|
||||
|
||||
bridge?.stopServer()
|
||||
bridge = null
|
||||
|
||||
mediaProjection?.stop()
|
||||
mediaProjection = null
|
||||
|
||||
Log.i(TAG, "LedGrab service destroyed")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"LedGrab Screen Capture",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Shows while LedGrab is capturing the screen"
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(url: String): Notification {
|
||||
val tapIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("LedGrab Running")
|
||||
.setContentText("Web UI: $url")
|
||||
.setSmallIcon(R.drawable.ic_launcher)
|
||||
.setContentIntent(tapIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.chaquo.python.Python
|
||||
import com.chaquo.python.android.AndroidPlatform
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Application class — initializes the Chaquopy Python runtime and
|
||||
* installs a global uncaught exception handler that persists crash
|
||||
* logs to app-private storage.
|
||||
*
|
||||
* `Python.start()` must be called once before any Python code runs.
|
||||
* It loads libpython, extracts stdlib + pip packages from APK assets
|
||||
* (first launch only), and sets up `sys.path`.
|
||||
*/
|
||||
class LedGrabApp : Application() {
|
||||
|
||||
/** Set if [Python.start] threw — surfaced by MainActivity. */
|
||||
@Volatile
|
||||
var initError: Throwable? = null
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
installCrashLogger()
|
||||
try {
|
||||
if (!Python.isStarted()) {
|
||||
Python.start(AndroidPlatform(this))
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
// Don't crash here — MainActivity will render a failure
|
||||
// screen with a Copy log button so the user can report it.
|
||||
Log.e(TAG, "Python.start() failed", t)
|
||||
initError = t
|
||||
return
|
||||
}
|
||||
// Bind application context for the USB-serial bridge so Python
|
||||
// can enumerate and open USB-to-TTL adapters without needing
|
||||
// an Activity reference.
|
||||
UsbSerialBridge.init(this)
|
||||
// Bind application context for the BLE bridge so Python can
|
||||
// scan and connect to BLE LED controllers.
|
||||
BleBridge.init(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a global uncaught exception handler that writes the
|
||||
* stack trace to `files/crash-<timestamp>.log` before letting
|
||||
* the default handler terminate the process. Logs survive app
|
||||
* restarts and can be pulled via `adb pull` for diagnostics.
|
||||
*/
|
||||
private fun installCrashLogger() {
|
||||
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
try {
|
||||
val ts = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
|
||||
val logFile = File(filesDir, "crash-$ts.log")
|
||||
PrintWriter(logFile).use { pw ->
|
||||
pw.println("LedGrab crash at $ts")
|
||||
pw.println("Thread: ${thread.name}")
|
||||
pw.println()
|
||||
throwable.printStackTrace(pw)
|
||||
}
|
||||
Log.e(TAG, "Crash log written to ${logFile.absolutePath}")
|
||||
} catch (_: Exception) {
|
||||
// Best effort — don't crash inside the crash handler.
|
||||
}
|
||||
// Chain to the default handler so Android shows the crash dialog
|
||||
// and terminates the process.
|
||||
defaultHandler?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LedGrabApp"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.CheckBox
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.app.Activity
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Main (and only) Activity for the Android TV app.
|
||||
*
|
||||
* Two-state UI: stopped (Start button) and running (URL + QR + Stop).
|
||||
* Navigable with D-pad / USB controller.
|
||||
*/
|
||||
class MainActivity : Activity() {
|
||||
|
||||
// Activity-scoped coroutine scope. We don't depend on AppCompat /
|
||||
// androidx.lifecycle's lifecycleScope here because the TV launcher
|
||||
// theme inherits from Leanback (non-AppCompat).
|
||||
private val uiScope: CoroutineScope = MainScope()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
private const val SERVER_PORT = 8080
|
||||
private const val REQUEST_MEDIA_PROJECTION = 1001
|
||||
private const val REQUEST_POST_NOTIFICATIONS = 1002
|
||||
}
|
||||
|
||||
private lateinit var stoppedPanel: View
|
||||
private lateinit var runningPanel: View
|
||||
private lateinit var statusText: TextView
|
||||
private lateinit var urlText: TextView
|
||||
private lateinit var qrImage: ImageView
|
||||
private lateinit var toggleButton: Button
|
||||
private lateinit var stopButtonRunning: Button
|
||||
private lateinit var versionText: TextView
|
||||
private lateinit var autostartCheck: CheckBox
|
||||
private lateinit var autostartPrefs: AutostartPrefs
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Surface fatal Python init errors instead of crashing.
|
||||
val initError = (application as? LedGrabApp)?.initError
|
||||
if (initError != null) {
|
||||
showFatalErrorScreen(initError)
|
||||
return
|
||||
}
|
||||
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
stoppedPanel = findViewById(R.id.stopped_panel)
|
||||
runningPanel = findViewById(R.id.running_panel)
|
||||
statusText = findViewById(R.id.status_text)
|
||||
urlText = findViewById(R.id.url_text)
|
||||
qrImage = findViewById(R.id.qr_image)
|
||||
toggleButton = findViewById(R.id.toggle_button)
|
||||
stopButtonRunning = findViewById(R.id.stop_button_running)
|
||||
versionText = findViewById(R.id.version_text)
|
||||
autostartCheck = findViewById(R.id.autostart_check)
|
||||
|
||||
val versionName = packageManager
|
||||
.getPackageInfo(packageName, 0).versionName
|
||||
versionText.text = getString(R.string.version_prefix, versionName ?: "?")
|
||||
|
||||
autostartPrefs = AutostartPrefs(this)
|
||||
autostartCheck.isChecked = autostartPrefs.isEnabled
|
||||
// Autostart only takes effect on rooted devices — grey it out
|
||||
// on unrooted hardware so users don't expect magic. Cheap probe
|
||||
// (file-existence only, no process spawn).
|
||||
if (!Root.looksRooted()) {
|
||||
autostartCheck.isEnabled = false
|
||||
autostartCheck.text = getString(R.string.autostart_unavailable)
|
||||
}
|
||||
autostartCheck.setOnCheckedChangeListener { _, isChecked ->
|
||||
autostartPrefs.isEnabled = isChecked
|
||||
if (isChecked) ensureIgnoringBatteryOptimizations()
|
||||
}
|
||||
|
||||
toggleButton.setOnClickListener { startCapture() }
|
||||
stopButtonRunning.setOnClickListener { stopCaptureService() }
|
||||
|
||||
updateUI()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to go through the MediaProjection consent flow or
|
||||
* jump straight into root capture. Root check is fast but may block
|
||||
* briefly the first time Magisk shows its grant dialog — running it
|
||||
* on the UI thread is acceptable because we're responding to a
|
||||
* button press and we want to block until the user answers.
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
uiScope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startCapture() {
|
||||
// `su -c id` can block for seconds while Magisk shows its grant
|
||||
// dialog; running it on the Main thread caused ANRs.
|
||||
toggleButton.isEnabled = false
|
||||
statusText.text = "Checking root access…"
|
||||
uiScope.launch(Dispatchers.IO) {
|
||||
val rooted = Root.requestGrant()
|
||||
withContext(Dispatchers.Main) {
|
||||
toggleButton.isEnabled = true
|
||||
statusText.text = ""
|
||||
if (rooted) {
|
||||
Log.i(TAG, "Root available — skipping MediaProjection consent")
|
||||
startRootCaptureService()
|
||||
} else {
|
||||
requestMediaProjection()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestMediaProjection() {
|
||||
val manager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityForResult(manager.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION)
|
||||
}
|
||||
|
||||
private fun startRootCaptureService() {
|
||||
ensureNotificationPermission()
|
||||
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
|
||||
updateUI()
|
||||
}
|
||||
|
||||
@Deprecated("Using deprecated API for plain Activity compatibility")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == REQUEST_MEDIA_PROJECTION) {
|
||||
if (resultCode == RESULT_OK && data != null) {
|
||||
startCaptureService(resultCode, data)
|
||||
} else {
|
||||
statusText.text = "Permission denied — screen capture requires authorization"
|
||||
Log.w(TAG, "MediaProjection permission denied")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCaptureService(resultCode: Int, resultData: Intent) {
|
||||
ensureNotificationPermission()
|
||||
val intent = CaptureService.createIntent(this, resultCode, resultData)
|
||||
ContextCompat.startForegroundService(this, intent)
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private fun stopCaptureService() {
|
||||
stopService(Intent(this, CaptureService::class.java))
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private fun updateUI() {
|
||||
if (CaptureService.isRunning) {
|
||||
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
|
||||
val url = "http://$localIp:$SERVER_PORT"
|
||||
|
||||
urlText.text = url
|
||||
qrImage.setImageBitmap(null)
|
||||
// Build the bitmap pixels off the Main thread — encode + 313k
|
||||
// setPixel calls were noticeably janky on slow TV boxes.
|
||||
uiScope.launch(Dispatchers.Default) {
|
||||
val bitmap = generateQrCode(url)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (CaptureService.isRunning && urlText.text == url) {
|
||||
qrImage.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stoppedPanel.visibility = View.GONE
|
||||
versionText.visibility = View.GONE
|
||||
runningPanel.visibility = View.VISIBLE
|
||||
stopButtonRunning.requestFocus()
|
||||
} else {
|
||||
urlText.text = ""
|
||||
qrImage.setImageBitmap(null)
|
||||
|
||||
runningPanel.visibility = View.GONE
|
||||
stoppedPanel.visibility = View.VISIBLE
|
||||
versionText.visibility = View.VISIBLE
|
||||
toggleButton.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateQrCode(text: String): Bitmap {
|
||||
val size = 560
|
||||
val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size)
|
||||
val pixels = IntArray(size * size)
|
||||
for (y in 0 until size) {
|
||||
val rowOffset = y * size
|
||||
for (x in 0 until size) {
|
||||
pixels[rowOffset + x] =
|
||||
if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
|
||||
}
|
||||
}
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565)
|
||||
bitmap.setPixels(pixels, 0, size, 0, 0, size, size)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal failure UI shown when Python.start() (Chaquopy) blew up.
|
||||
* Rendered programmatically so we don't depend on the regular layout
|
||||
* (which itself may reference resources affected by the failure).
|
||||
*/
|
||||
private fun showFatalErrorScreen(error: Throwable) {
|
||||
Log.e(TAG, "Fatal init error — showing error screen", error)
|
||||
val stackText = android.util.Log.getStackTraceString(error)
|
||||
val container = android.widget.LinearLayout(this).apply {
|
||||
orientation = android.widget.LinearLayout.VERTICAL
|
||||
setPadding(48, 48, 48, 48)
|
||||
}
|
||||
val title = TextView(this).apply {
|
||||
text = "LedGrab failed to start"
|
||||
textSize = 22f
|
||||
}
|
||||
val body = TextView(this).apply {
|
||||
text = "Python runtime initialization failed:\n\n$stackText"
|
||||
textSize = 12f
|
||||
setTextIsSelectable(true)
|
||||
}
|
||||
val copyBtn = Button(this).apply {
|
||||
text = "Copy log"
|
||||
setOnClickListener {
|
||||
val cm = getSystemService(CLIPBOARD_SERVICE)
|
||||
as android.content.ClipboardManager
|
||||
cm.setPrimaryClip(
|
||||
android.content.ClipData.newPlainText("LedGrab error", stackText)
|
||||
)
|
||||
}
|
||||
}
|
||||
val scroll = android.widget.ScrollView(this).apply { addView(body) }
|
||||
container.addView(title)
|
||||
container.addView(copyBtn)
|
||||
container.addView(scroll)
|
||||
setContentView(container)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user to exempt LedGrab from battery optimization. On
|
||||
* TV boxes this is usually a no-op, but on phones Doze/App Standby
|
||||
* will kill the foreground service after a few hours of sleep. We
|
||||
* only ask when autostart is turned on. No-op on pre-M or when
|
||||
* already exempt.
|
||||
*
|
||||
* Play Store flags REQUEST_IGNORE_BATTERY_OPTIMIZATIONS by default
|
||||
* — LedGrab's ambient-capture use case falls under the documented
|
||||
* acceptable-use exceptions (a foreground service that must not be
|
||||
* killed to fulfill its primary function).
|
||||
*/
|
||||
@SuppressLint("BatteryLife")
|
||||
private fun ensureIgnoringBatteryOptimizations() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
|
||||
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
||||
if (pm.isIgnoringBatteryOptimizations(packageName)) return
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:$packageName")
|
||||
}
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
// Some TV-box OEM builds strip this intent. Fall back to the
|
||||
// generic settings screen so the user can find it manually.
|
||||
Log.w(TAG, "Direct exemption intent unavailable: ${e.message}")
|
||||
runCatching {
|
||||
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request POST_NOTIFICATIONS permission on Android 13+ so the
|
||||
* foreground service notification is visible. On older API levels
|
||||
* this is a no-op.
|
||||
*/
|
||||
private fun ensureNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
REQUEST_POST_NOTIFICATIONS,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkProperties
|
||||
import java.net.Inet4Address
|
||||
|
||||
/**
|
||||
* Network utilities for discovering the device's local IP address.
|
||||
*/
|
||||
object NetworkUtils {
|
||||
|
||||
/**
|
||||
* Return the device's local IPv4 address on the active network,
|
||||
* or `null` if unavailable.
|
||||
*/
|
||||
fun getLocalIpAddress(context: Context): String? {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = cm.activeNetwork ?: return null
|
||||
val props: LinkProperties = cm.getLinkProperties(network) ?: return null
|
||||
|
||||
return props.linkAddresses
|
||||
.map { it.address }
|
||||
.filterIsInstance<Inet4Address>()
|
||||
.firstOrNull { !it.isLoopbackAddress }
|
||||
?.hostAddress
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.chaquo.python.PyObject
|
||||
import com.chaquo.python.Python
|
||||
|
||||
/**
|
||||
* Bridge between Kotlin and the LedGrab Python server.
|
||||
*
|
||||
* All Python calls go through Chaquopy's `Python.getInstance()`.
|
||||
* Frame data crosses the JNI boundary as a `ByteArray`.
|
||||
*/
|
||||
class PythonBridge(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PythonBridge"
|
||||
}
|
||||
|
||||
private var serverThread: Thread? = null
|
||||
@Volatile private var running = false
|
||||
|
||||
// Cached PyObject handles for the per-frame fast path. Looking these
|
||||
// up via Python.getInstance().getModule(...) every frame was a real
|
||||
// measurable cost (~1ms/frame on TV boxes). Cached once at configure
|
||||
// time and read on the capture thread — @Volatile is enough for the
|
||||
// single-writer/single-reader pattern we have here.
|
||||
@Volatile private var mediaProjectionEngine: PyObject? = null
|
||||
@Volatile private var rootEngine: PyObject? = null
|
||||
|
||||
/**
|
||||
* Configure the MediaProjection engine with screen dimensions.
|
||||
* Must be called before [startServer].
|
||||
*/
|
||||
fun configureCapture(width: Int, height: Int) {
|
||||
val py = Python.getInstance()
|
||||
val engine = py.getModule("ledgrab.core.capture_engines.mediaprojection_engine")
|
||||
engine.callAttr("configure", width, height)
|
||||
mediaProjectionEngine = engine
|
||||
Log.i(TAG, "MediaProjection engine configured: ${width}x${height}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the root screenrecord engine with screen dimensions.
|
||||
* Used instead of [configureCapture] when root is available.
|
||||
*/
|
||||
fun configureRootCapture(width: Int, height: Int) {
|
||||
val py = Python.getInstance()
|
||||
val engine = py.getModule("ledgrab.core.capture_engines.root_screenrecord_engine")
|
||||
engine.callAttr("configure", width, height)
|
||||
rootEngine = engine
|
||||
Log.i(TAG, "Root screenrecord engine configured: ${width}x${height}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the LedGrab FastAPI server on a background thread.
|
||||
*
|
||||
* This blocks until [stopServer] is called, so it runs in its own thread.
|
||||
*/
|
||||
fun startServer(port: Int = 8080) {
|
||||
if (running) {
|
||||
Log.w(TAG, "Server already running")
|
||||
return
|
||||
}
|
||||
|
||||
running = true
|
||||
val dataDir = context.filesDir.absolutePath
|
||||
|
||||
serverThread = Thread({
|
||||
try {
|
||||
Log.i(TAG, "Starting Python server (dataDir=$dataDir, port=$port)")
|
||||
val py = Python.getInstance()
|
||||
val entry = py.getModule("ledgrab.android_entry")
|
||||
entry.callAttr("start_server", dataDir, port)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Python server error", e)
|
||||
} finally {
|
||||
running = false
|
||||
}
|
||||
}, "ledgrab-python-server")
|
||||
serverThread?.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal the Python server to shut down gracefully.
|
||||
*/
|
||||
fun stopServer() {
|
||||
if (!running) return
|
||||
|
||||
try {
|
||||
val py = Python.getInstance()
|
||||
val entry = py.getModule("ledgrab.android_entry")
|
||||
entry.callAttr("stop_server")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error stopping server", e)
|
||||
}
|
||||
|
||||
serverThread?.join(10_000)
|
||||
serverThread = null
|
||||
running = false
|
||||
Log.i(TAG, "Server stopped")
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a captured RGBA frame to the Python MediaProjection engine.
|
||||
*
|
||||
* Called from [ScreenCapture] on the capture thread. The byte array
|
||||
* crosses the JNI boundary — keep frames small (downscale to 480p
|
||||
* before calling).
|
||||
*/
|
||||
fun pushFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
|
||||
if (!running) return
|
||||
val engine = mediaProjectionEngine ?: return
|
||||
|
||||
try {
|
||||
engine.callAttr("push_frame", rgbaBytes, width, height)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to push frame: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a frame produced by the root `screenrecord` pipeline.
|
||||
*
|
||||
* Routed to a separate Python module so the two capture paths stay
|
||||
* independently introspectable (engine priority, availability flags,
|
||||
* dashboard diagnostics).
|
||||
*/
|
||||
fun pushRootFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
|
||||
if (!running) return
|
||||
val engine = rootEngine ?: return
|
||||
|
||||
try {
|
||||
engine.callAttr("push_frame", rgbaBytes, width, height)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to push root frame: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
val isRunning: Boolean get() = running
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.util.Log
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Lightweight root-access utilities.
|
||||
*
|
||||
* Detection is two-phase:
|
||||
* 1. Cheap check for a `su` binary in common paths (no process spawn).
|
||||
* 2. On demand, a real `su -c id` execution that returns true only when
|
||||
* the shell actually runs as UID 0. This triggers Magisk's grant
|
||||
* dialog the first time, exactly like any other root request.
|
||||
*
|
||||
* The heavier check is cached after it succeeds so we don't ask Magisk
|
||||
* repeatedly mid-session.
|
||||
*/
|
||||
object Root {
|
||||
private const val TAG = "Root"
|
||||
|
||||
private val SU_PATHS = listOf(
|
||||
"/system/bin/su",
|
||||
"/system/xbin/su",
|
||||
"/sbin/su",
|
||||
"/su/bin/su",
|
||||
"/debug_ramdisk/su",
|
||||
"/vendor/bin/su",
|
||||
)
|
||||
|
||||
@Volatile private var cachedGranted: Boolean? = null
|
||||
|
||||
/** Cheap probe: true if a known `su` binary exists. Doesn't run anything. */
|
||||
@JvmStatic
|
||||
fun looksRooted(): Boolean = SU_PATHS.any { java.io.File(it).exists() }
|
||||
|
||||
/**
|
||||
* Actually exercise `su`. Triggers Magisk's grant dialog on first call.
|
||||
* Returns true if the shell reports UID 0 within the timeout; false
|
||||
* otherwise (no root, user denied, or timeout).
|
||||
*/
|
||||
@JvmStatic
|
||||
fun requestGrant(timeoutSeconds: Long = 10): Boolean {
|
||||
cachedGranted?.let { return it }
|
||||
|
||||
if (!looksRooted()) {
|
||||
cachedGranted = false
|
||||
return false
|
||||
}
|
||||
|
||||
val granted = try {
|
||||
// redirectErrorStream merges stderr into stdout so a single
|
||||
// drain thread is enough — avoids the classic pipe-buffer
|
||||
// deadlock where waitFor() blocks because stderr filled up.
|
||||
val process = ProcessBuilder("su", "-c", "id")
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val outputBuilder = StringBuilder()
|
||||
val drain = Thread({
|
||||
try {
|
||||
BufferedReader(InputStreamReader(process.inputStream)).use { r ->
|
||||
val buf = CharArray(512)
|
||||
while (true) {
|
||||
val n = r.read(buf)
|
||||
if (n < 0) break
|
||||
synchronized(outputBuilder) { outputBuilder.append(buf, 0, n) }
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Process gone — drain ends.
|
||||
}
|
||||
}, "Root-su-drain").apply { isDaemon = true; start() }
|
||||
|
||||
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
||||
if (!finished) {
|
||||
process.destroyForcibly()
|
||||
drain.join(500)
|
||||
Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s")
|
||||
false
|
||||
} else {
|
||||
drain.join(500)
|
||||
val output = synchronized(outputBuilder) { outputBuilder.toString() }
|
||||
if (process.exitValue() != 0) {
|
||||
Log.w(TAG, "su -c id exited with ${process.exitValue()} output='${output.trim()}'")
|
||||
false
|
||||
} else {
|
||||
val rooted = output.contains("uid=0")
|
||||
Log.i(TAG, "su -c id → '${output.trim()}' → rooted=$rooted")
|
||||
rooted
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "su invocation failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
|
||||
cachedGranted = granted
|
||||
return granted
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an `su -c <cmd>` command. Returns true on exit-zero. Failure
|
||||
* invalidates the cached grant so the next [requestGrant] re-checks
|
||||
* (covers cases like Magisk grant being revoked mid-session).
|
||||
*/
|
||||
@JvmStatic
|
||||
fun runAsRoot(cmd: String, timeoutSeconds: Long = 5): Boolean {
|
||||
return try {
|
||||
val process = ProcessBuilder("su", "-c", cmd)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
||||
if (!finished) {
|
||||
process.destroyForcibly()
|
||||
cachedGranted = null
|
||||
false
|
||||
} else if (process.exitValue() != 0) {
|
||||
cachedGranted = null
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "runAsRoot('$cmd') failed: ${e.message}")
|
||||
cachedGranted = null
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget the cached grant result — useful if Magisk permission was revoked. */
|
||||
@JvmStatic
|
||||
fun invalidateCache() {
|
||||
cachedGranted = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.graphics.PixelFormat
|
||||
import android.media.ImageReader
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaFormat
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import java.io.InputStream
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Root-only screen capture backed by `/system/bin/screenrecord`.
|
||||
*
|
||||
* When the device is rooted (Magisk), we spawn ``su -c screenrecord
|
||||
* --output-format=h264 …`` and read the encoder's H.264 bitstream from
|
||||
* stdout. A MediaCodec decoder is configured to render into an
|
||||
* ImageReader's Surface, and the ImageReader's `onImageAvailable`
|
||||
* callback feeds RGBA bytes back to the Python pipeline — same sink as
|
||||
* the MediaProjection path, just a different source.
|
||||
*
|
||||
* Why bother: MediaProjection forces Android to draw a persistent
|
||||
* capture indicator overlay (unavoidable on stock Android 14+). The
|
||||
* root path sidesteps that entirely because `screenrecord` runs as
|
||||
* system UID through `su`. It also skips the one-time MediaProjection
|
||||
* consent dialog, which matters on TV-only setups where keyboard input
|
||||
* is awkward.
|
||||
*/
|
||||
class RootScreenrecord(
|
||||
private val bridge: PythonBridge,
|
||||
private val width: Int = 480,
|
||||
private val height: Int = 270,
|
||||
private val bitRate: Int = 4_000_000,
|
||||
private val fps: Int = 30,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "RootScreenrecord"
|
||||
private const val MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC
|
||||
private const val INPUT_CHUNK = 64 * 1024
|
||||
}
|
||||
|
||||
@Volatile private var process: Process? = null
|
||||
private var decoder: MediaCodec? = null
|
||||
private var imageReader: ImageReader? = null
|
||||
private var readerThread: HandlerThread? = null
|
||||
private var inputThread: Thread? = null
|
||||
private var outputThread: Thread? = null
|
||||
@Volatile private var running = false
|
||||
private val framesDeliveredCounter = AtomicInteger(0)
|
||||
@Volatile private var stopped = false
|
||||
|
||||
/** Monotonic count of frames pushed to the Python bridge. */
|
||||
val framesDelivered: Int get() = framesDeliveredCounter.get()
|
||||
|
||||
/** True once at least one frame has reached the Python bridge. */
|
||||
val hasProducedFrame: Boolean get() = framesDelivered > 0
|
||||
|
||||
/**
|
||||
* Start the capture pipeline. Returns false if `su`/`screenrecord`
|
||||
* couldn't be launched at all; true doesn't guarantee frames will
|
||||
* actually flow (the caller should monitor [hasProducedFrame] and
|
||||
* be ready to fall back to MediaProjection on timeout).
|
||||
*/
|
||||
fun start(): Boolean {
|
||||
if (running) return true
|
||||
running = true
|
||||
|
||||
try {
|
||||
imageReader = buildImageReader()
|
||||
decoder = buildDecoder(imageReader!!)
|
||||
process = spawnScreenrecord() ?: run {
|
||||
stop()
|
||||
return false
|
||||
}
|
||||
startInputPump(process!!.inputStream, decoder!!)
|
||||
startOutputDrain(decoder!!)
|
||||
Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)")
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start root capture", e)
|
||||
stop()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop everything and release resources. Idempotent. */
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
// Order matters: signal first so worker loops drop out, then
|
||||
// stop the codec on the thread that created it (this one), then
|
||||
// join workers BEFORE releasing the codec/ImageReader they may
|
||||
// still be touching, then kill the external screenrecord process.
|
||||
running = false
|
||||
|
||||
runCatching { decoder?.stop() }
|
||||
|
||||
inputThread?.interrupt()
|
||||
outputThread?.interrupt()
|
||||
runCatching { inputThread?.join(500) }
|
||||
runCatching { outputThread?.join(500) }
|
||||
inputThread = null
|
||||
outputThread = null
|
||||
|
||||
// Best-effort: kill the screenrecord child before reaping `su`,
|
||||
// otherwise screenrecord can outlive su as an orphan and keep
|
||||
// the GPU encoder busy. Fire-and-forget; ignore failures.
|
||||
runCatching { Root.runAsRoot("pkill -TERM screenrecord", timeoutSeconds = 2) }
|
||||
|
||||
runCatching { decoder?.release() }
|
||||
decoder = null
|
||||
|
||||
runCatching { imageReader?.setOnImageAvailableListener(null, null) }
|
||||
runCatching { imageReader?.close() }
|
||||
imageReader = null
|
||||
|
||||
readerThread?.quitSafely()
|
||||
runCatching { readerThread?.join(500) }
|
||||
readerThread = null
|
||||
|
||||
runCatching { process?.destroy() }
|
||||
process = null
|
||||
|
||||
Log.i(TAG, "Root capture pipeline stopped (frames delivered: ${framesDelivered})")
|
||||
}
|
||||
|
||||
private fun buildImageReader(): ImageReader {
|
||||
val thread = HandlerThread("RootReader").apply { start() }
|
||||
readerThread = thread
|
||||
val handler = Handler(thread.looper)
|
||||
|
||||
val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
|
||||
reader.setOnImageAvailableListener({ r ->
|
||||
val image = r.acquireLatestImage() ?: return@setOnImageAvailableListener
|
||||
try {
|
||||
val plane = image.planes[0]
|
||||
val buffer = plane.buffer
|
||||
val rowStride = plane.rowStride
|
||||
val pixelStride = plane.pixelStride
|
||||
val bytes = if (rowStride == width * pixelStride) {
|
||||
ByteArray(buffer.remaining()).also { buffer.get(it) }
|
||||
} else {
|
||||
// Strip row padding — common when width isn't a multiple of 16.
|
||||
val rowBytes = width * pixelStride
|
||||
ByteArray(width * height * 4).also { out ->
|
||||
for (row in 0 until height) {
|
||||
buffer.position(row * rowStride)
|
||||
buffer.get(out, row * rowBytes, rowBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
bridge.pushRootFrame(bytes, width, height)
|
||||
framesDeliveredCounter.incrementAndGet()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Root frame delivery failed: ${e.message}")
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
}, handler)
|
||||
return reader
|
||||
}
|
||||
|
||||
private fun buildDecoder(reader: ImageReader): MediaCodec {
|
||||
val format = MediaFormat.createVideoFormat(MIME_TYPE, width, height).apply {
|
||||
setInteger(MediaFormat.KEY_FRAME_RATE, fps)
|
||||
}
|
||||
val codec = MediaCodec.createDecoderByType(MIME_TYPE)
|
||||
codec.configure(format, reader.surface, null, 0)
|
||||
codec.start()
|
||||
return codec
|
||||
}
|
||||
|
||||
private fun spawnScreenrecord(): Process? {
|
||||
val cmd = buildString {
|
||||
append("screenrecord")
|
||||
append(" --output-format=h264")
|
||||
append(" --size=${width}x$height")
|
||||
append(" --bit-rate=$bitRate")
|
||||
// Time limit 0 isn't supported; the largest accepted is 180s.
|
||||
// We restart the process ourselves if it exits early.
|
||||
append(" --time-limit=180")
|
||||
append(" -")
|
||||
}
|
||||
return try {
|
||||
Runtime.getRuntime().exec(arrayOf("su", "-c", cmd))
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to spawn `su -c screenrecord`: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun startInputPump(initialStream: InputStream, codec: MediaCodec) {
|
||||
inputThread = Thread({
|
||||
val buf = ByteArray(INPUT_CHUNK)
|
||||
var stream: InputStream = initialStream
|
||||
try {
|
||||
outer@ while (running) {
|
||||
val n = try {
|
||||
stream.read(buf)
|
||||
} catch (e: Exception) {
|
||||
if (!running) break
|
||||
Log.w(TAG, "screenrecord read error: ${e.message}")
|
||||
-1
|
||||
}
|
||||
if (n <= 0) {
|
||||
if (!running) break
|
||||
// screenrecord caps at --time-limit=180s. When it
|
||||
// exits cleanly we respawn so capture survives
|
||||
// long sessions instead of freezing after ~3min.
|
||||
Log.i(TAG, "screenrecord EOF — respawning")
|
||||
runCatching { process?.destroy() }
|
||||
val next = spawnScreenrecord()
|
||||
if (next == null) {
|
||||
// Avoid a tight loop if `su` is suddenly unhappy.
|
||||
try { Thread.sleep(500) } catch (_: InterruptedException) { break }
|
||||
continue@outer
|
||||
}
|
||||
process = next
|
||||
stream = next.inputStream
|
||||
continue@outer
|
||||
}
|
||||
var offset = 0
|
||||
while (offset < n && running) {
|
||||
val index = codec.dequeueInputBuffer(50_000)
|
||||
if (index < 0) continue
|
||||
val inputBuffer = codec.getInputBuffer(index) ?: continue
|
||||
inputBuffer.clear()
|
||||
val chunk = minOf(n - offset, inputBuffer.capacity())
|
||||
inputBuffer.put(buf, offset, chunk)
|
||||
codec.queueInputBuffer(
|
||||
index,
|
||||
0,
|
||||
chunk,
|
||||
System.nanoTime() / 1_000,
|
||||
0,
|
||||
)
|
||||
offset += chunk
|
||||
}
|
||||
}
|
||||
} catch (_: InterruptedException) {
|
||||
// Expected on stop()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Input pump error: ${e.message}")
|
||||
}
|
||||
}, "RootDecoderInput").also { it.start() }
|
||||
}
|
||||
|
||||
private fun startOutputDrain(codec: MediaCodec) {
|
||||
outputThread = Thread({
|
||||
val info = MediaCodec.BufferInfo()
|
||||
try {
|
||||
while (running) {
|
||||
val index = codec.dequeueOutputBuffer(info, 50_000)
|
||||
if (index >= 0) {
|
||||
// `true` = render to the configured surface (ImageReader),
|
||||
// which triggers onImageAvailable on the reader's handler.
|
||||
codec.releaseOutputBuffer(index, true)
|
||||
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
||||
Log.i(TAG, "Decoder reported EOS")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: InterruptedException) {
|
||||
// Expected on stop()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Output drain error: ${e.message}")
|
||||
}
|
||||
}, "RootDecoderOutput").also { it.start() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PixelFormat
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.hardware.display.VirtualDisplay
|
||||
import android.media.ImageReader
|
||||
import android.media.projection.MediaProjection
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Captures the Android screen via MediaProjection and feeds frames
|
||||
* to [PythonBridge].
|
||||
*
|
||||
* Frames are downscaled to [targetWidth] x [targetHeight] before
|
||||
* crossing the JNI boundary to minimize overhead. For LED ambient
|
||||
* lighting, even 480x270 contains far more data than needed.
|
||||
*/
|
||||
class ScreenCapture(
|
||||
private val projection: MediaProjection,
|
||||
private val metrics: DisplayMetrics,
|
||||
private val bridge: PythonBridge,
|
||||
private val targetWidth: Int = 480,
|
||||
private val targetHeight: Int = 270,
|
||||
private val targetFps: Int = 30,
|
||||
private val onProjectionStopped: () -> Unit = {},
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "ScreenCapture"
|
||||
private const val VIRTUAL_DISPLAY_NAME = "LedGrabCapture"
|
||||
}
|
||||
|
||||
private var virtualDisplay: VirtualDisplay? = null
|
||||
private var imageReader: ImageReader? = null
|
||||
private var captureThread: HandlerThread? = null
|
||||
private var captureHandler: Handler? = null
|
||||
@Volatile private var running = false
|
||||
private var lastFrameTimeMs = 0L
|
||||
private val frameIntervalMs = 1000L / targetFps
|
||||
|
||||
/**
|
||||
* Start capturing the screen.
|
||||
*/
|
||||
fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
|
||||
captureThread = HandlerThread("LedGrab-Capture").also { it.start() }
|
||||
captureHandler = Handler(captureThread!!.looper)
|
||||
|
||||
// Android 14+ requires registering a callback before createVirtualDisplay
|
||||
projection.registerCallback(object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
Log.i(TAG, "MediaProjection stopped (external)")
|
||||
stop()
|
||||
// Notify the service so the foreground notification /
|
||||
// Python server get torn down too — otherwise a stale
|
||||
// "Running" notification lingers after the user taps
|
||||
// Android's system Cast/Screen-capture stop banner.
|
||||
onProjectionStopped()
|
||||
}
|
||||
}, captureHandler)
|
||||
|
||||
imageReader = ImageReader.newInstance(
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
PixelFormat.RGBA_8888,
|
||||
2, // maxImages — double buffer
|
||||
)
|
||||
|
||||
imageReader?.setOnImageAvailableListener({ reader ->
|
||||
if (!running) return@setOnImageAvailableListener
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastFrameTimeMs < frameIntervalMs) {
|
||||
// Skip frame to maintain target FPS
|
||||
reader.acquireLatestImage()?.close()
|
||||
return@setOnImageAvailableListener
|
||||
}
|
||||
|
||||
val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener
|
||||
try {
|
||||
val plane = image.planes[0]
|
||||
val buffer = plane.buffer
|
||||
val rowStride = plane.rowStride
|
||||
val pixelStride = plane.pixelStride
|
||||
|
||||
// Handle row padding: rowStride may be > width * pixelStride
|
||||
val rgbaBytes = if (rowStride == targetWidth * pixelStride) {
|
||||
// No padding — direct copy
|
||||
val bytes = ByteArray(buffer.remaining())
|
||||
buffer.get(bytes)
|
||||
bytes
|
||||
} else {
|
||||
// Strip row padding
|
||||
val rowBytes = targetWidth * pixelStride
|
||||
val bytes = ByteArray(targetWidth * targetHeight * 4)
|
||||
for (row in 0 until targetHeight) {
|
||||
buffer.position(row * rowStride)
|
||||
buffer.get(bytes, row * rowBytes, rowBytes)
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
bridge.pushFrame(rgbaBytes, targetWidth, targetHeight)
|
||||
lastFrameTimeMs = now
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Frame processing error: ${e.message}")
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
}, captureHandler)
|
||||
|
||||
virtualDisplay = projection.createVirtualDisplay(
|
||||
VIRTUAL_DISPLAY_NAME,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
metrics.densityDpi,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
imageReader?.surface,
|
||||
null,
|
||||
captureHandler,
|
||||
)
|
||||
|
||||
Log.i(TAG, "Screen capture started (${targetWidth}x${targetHeight} @ ${targetFps}fps)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop capturing and release all resources.
|
||||
*/
|
||||
fun stop() {
|
||||
running = false
|
||||
// Order matters: detach the listener BEFORE releasing the
|
||||
// VirtualDisplay so the handler can't be re-entered with stale
|
||||
// resources, then quit & join the handler thread, only then
|
||||
// close the ImageReader.
|
||||
runCatching { imageReader?.setOnImageAvailableListener(null, null) }
|
||||
runCatching { virtualDisplay?.release() }
|
||||
virtualDisplay = null
|
||||
|
||||
captureThread?.quitSafely()
|
||||
runCatching { captureThread?.join(500) }
|
||||
captureThread = null
|
||||
captureHandler = null
|
||||
|
||||
runCatching { imageReader?.close() }
|
||||
imageReader = null
|
||||
|
||||
Log.i(TAG, "Screen capture stopped")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
||||
import com.hoho.android.usbserial.driver.UsbSerialPort
|
||||
import com.hoho.android.usbserial.driver.UsbSerialProber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
|
||||
/**
|
||||
* USB-serial bridge exposed to the Python server via Chaquopy.
|
||||
*
|
||||
* Uses the `usb-serial-for-android` library (mik3y) which ships drivers
|
||||
* for the common USB-to-TTL chips (CH340, CP2102, FTDI, Prolific, and
|
||||
* CDC-ACM) found on Arduino boards and Adalight/AmbiLED controllers.
|
||||
*
|
||||
* Python callers access the singleton instance via
|
||||
* `UsbSerialBridge.INSTANCE.listDevices()` etc. — see
|
||||
* `server/src/ledgrab/core/devices/android_serial_transport.py`.
|
||||
*
|
||||
* The bridge holds no Context of its own; [init] must be called once
|
||||
* from [LedGrabApp.onCreate] to bind the application context.
|
||||
*/
|
||||
object UsbSerialBridge {
|
||||
private const val TAG = "UsbSerialBridge"
|
||||
private const val ACTION_USB_PERMISSION = "com.ledgrab.android.USB_PERMISSION"
|
||||
|
||||
@Volatile private var appContext: Context? = null
|
||||
|
||||
private val handleSeq = AtomicInteger(1)
|
||||
private val openPorts = HashMap<Int, UsbSerialPort>()
|
||||
private val initialized = AtomicBoolean(false)
|
||||
private val pendingPermissions = ConcurrentHashMap<String, CompletableDeferred<Boolean>>()
|
||||
|
||||
/** Called once from [LedGrabApp.onCreate] so we can resolve services. */
|
||||
@JvmStatic
|
||||
fun init(context: Context) {
|
||||
val app = context.applicationContext
|
||||
appContext = app
|
||||
// Idempotent: re-entrant init() must not double-register the
|
||||
// receiver (which would leak listeners and double-fire callbacks).
|
||||
if (!initialized.compareAndSet(false, true)) return
|
||||
|
||||
val filter = IntentFilter(ACTION_USB_PERMISSION)
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
val granted = intent.getBooleanExtra(
|
||||
UsbManager.EXTRA_PERMISSION_GRANTED,
|
||||
false,
|
||||
)
|
||||
val device = intent.getParcelableExtra<android.hardware.usb.UsbDevice>(
|
||||
UsbManager.EXTRA_DEVICE,
|
||||
)
|
||||
Log.i(TAG, "USB permission broadcast: granted=$granted device=${device?.deviceName}")
|
||||
device?.deviceName?.let { name ->
|
||||
pendingPermissions.remove(name)?.complete(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Android 14 requires RECEIVER_NOT_EXPORTED for non-system broadcasts.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
app.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
app.registerReceiver(receiver, filter)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ctx(): Context =
|
||||
appContext ?: error("UsbSerialBridge.init() not called — app context unavailable")
|
||||
|
||||
private fun safeSerial(driver: UsbSerialDriver): String =
|
||||
try {
|
||||
driver.device.serialNumber ?: ""
|
||||
} catch (_: SecurityException) {
|
||||
// Reading the serial requires USB permission on API 29+.
|
||||
""
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate attached USB-serial devices.
|
||||
*
|
||||
* Each entry is `"VID|PID|serial|description"` with VID/PID as
|
||||
* 4-char lowercase hex. Pipe is used as the separator so device
|
||||
* descriptions containing colons (common on FTDI strings) don't
|
||||
* confuse the Python parser.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun listDevices(): List<String> {
|
||||
val manager = ctx().getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val drivers = UsbSerialProber.getDefaultProber().findAllDrivers(manager)
|
||||
return drivers.map { driver ->
|
||||
val dev = driver.device
|
||||
val vid = "%04x".format(dev.vendorId)
|
||||
val pid = "%04x".format(dev.productId)
|
||||
val serial = safeSerial(driver)
|
||||
val description = buildString {
|
||||
append(dev.manufacturerName ?: "USB")
|
||||
val product = dev.productName
|
||||
if (!product.isNullOrBlank()) {
|
||||
append(' ')
|
||||
append(product)
|
||||
}
|
||||
}.trim().ifEmpty { "USB $vid:$pid" }
|
||||
"$vid|$pid|$serial|$description"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the first matching USB-serial device. Returns a non-negative
|
||||
* opaque handle on success, -1 on failure (device not found, user
|
||||
* denied permission, or driver error). Failures also trigger an
|
||||
* async permission-request dialog when applicable — subsequent
|
||||
* open() calls will succeed once the user grants.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun open(vendorId: Int, productId: Int, serial: String, baud: Int): Int {
|
||||
val context = ctx()
|
||||
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val drivers = UsbSerialProber.getDefaultProber().findAllDrivers(manager)
|
||||
val driver = drivers.firstOrNull { d ->
|
||||
val dev = d.device
|
||||
dev.vendorId == vendorId &&
|
||||
dev.productId == productId &&
|
||||
(serial.isEmpty() || safeSerial(d) == serial)
|
||||
}
|
||||
if (driver == null) {
|
||||
Log.w(TAG, "No matching device for $vendorId:$productId:$serial")
|
||||
return -1
|
||||
}
|
||||
|
||||
if (!manager.hasPermission(driver.device)) {
|
||||
Log.w(TAG, "USB permission not yet granted for ${driver.device.deviceName}")
|
||||
requestPermission(context, manager, driver)
|
||||
return -1
|
||||
}
|
||||
|
||||
val connection = manager.openDevice(driver.device)
|
||||
if (connection == null) {
|
||||
Log.w(TAG, "openDevice returned null for ${driver.device.deviceName}")
|
||||
return -1
|
||||
}
|
||||
val port = driver.ports.firstOrNull()
|
||||
if (port == null) {
|
||||
connection.close()
|
||||
Log.w(TAG, "Driver reports no ports for ${driver.device.deviceName}")
|
||||
return -1
|
||||
}
|
||||
|
||||
try {
|
||||
port.open(connection)
|
||||
port.setParameters(
|
||||
baud,
|
||||
8,
|
||||
UsbSerialPort.STOPBITS_1,
|
||||
UsbSerialPort.PARITY_NONE,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to configure serial port", e)
|
||||
runCatching { port.close() }
|
||||
return -1
|
||||
}
|
||||
|
||||
val handle = handleSeq.getAndIncrement()
|
||||
synchronized(openPorts) { openPorts[handle] = port }
|
||||
Log.i(
|
||||
TAG,
|
||||
"Opened USB serial ${driver.device.deviceName} baud=$baud handle=$handle",
|
||||
)
|
||||
return handle
|
||||
}
|
||||
|
||||
/** Write bytes to the previously-opened handle. Throws if invalid. */
|
||||
@JvmStatic
|
||||
fun write(handle: Int, data: ByteArray) {
|
||||
val port = synchronized(openPorts) { openPorts[handle] }
|
||||
?: throw IllegalStateException("Invalid handle $handle")
|
||||
// 1s write timeout matches the old pyserial `timeout=1` behavior.
|
||||
port.write(data, 1_000)
|
||||
}
|
||||
|
||||
/** Close a previously-opened handle. Silently ignores unknown handles. */
|
||||
@JvmStatic
|
||||
fun close(handle: Int) {
|
||||
val port = synchronized(openPorts) { openPorts.remove(handle) } ?: return
|
||||
runCatching { port.close() }
|
||||
.onFailure { Log.w(TAG, "close($handle): ${it.message}") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Block until the user grants (or denies) USB permission for the
|
||||
* device with [deviceName] (e.g. "/dev/bus/usb/001/004"). Returns
|
||||
* true if granted within [timeoutMs], false otherwise. Safe to call
|
||||
* from a Python thread via Chaquopy.
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun requestPermissionBlocking(deviceName: String, timeoutMs: Long = 15_000L): Boolean {
|
||||
val context = ctx()
|
||||
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val driver = UsbSerialProber.getDefaultProber().findAllDrivers(manager)
|
||||
.firstOrNull { it.device.deviceName == deviceName }
|
||||
?: return false
|
||||
if (manager.hasPermission(driver.device)) return true
|
||||
|
||||
// Coalesce concurrent requests for the same device — only the
|
||||
// first caller actually fires the system dialog.
|
||||
val deferred = pendingPermissions.computeIfAbsent(deviceName) {
|
||||
CompletableDeferred<Boolean>().also {
|
||||
requestPermission(context, manager, driver)
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
runBlocking {
|
||||
withTimeout(timeoutMs) { deferred.await() }
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
pendingPermissions.remove(deviceName)
|
||||
Log.w(TAG, "Permission request timed out for $deviceName")
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
pendingPermissions.remove(deviceName)
|
||||
Log.w(TAG, "Permission request failed for $deviceName: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like [open] but blocks for permission first. Use this from Python
|
||||
* instead of relying on the open()/retry pattern.
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun openWithPermission(
|
||||
vendorId: Int,
|
||||
productId: Int,
|
||||
serial: String,
|
||||
baud: Int,
|
||||
timeoutMs: Long = 15_000L,
|
||||
): Int {
|
||||
val context = ctx()
|
||||
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val driver = UsbSerialProber.getDefaultProber().findAllDrivers(manager)
|
||||
.firstOrNull { d ->
|
||||
val dev = d.device
|
||||
dev.vendorId == vendorId &&
|
||||
dev.productId == productId &&
|
||||
(serial.isEmpty() || safeSerial(d) == serial)
|
||||
} ?: return -1
|
||||
|
||||
if (!manager.hasPermission(driver.device)) {
|
||||
val granted = requestPermissionBlocking(driver.device.deviceName, timeoutMs)
|
||||
if (!granted) return -1
|
||||
}
|
||||
return open(vendorId, productId, serial, baud)
|
||||
}
|
||||
|
||||
private fun requestPermission(
|
||||
context: Context,
|
||||
manager: UsbManager,
|
||||
driver: UsbSerialDriver,
|
||||
) {
|
||||
val flags =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val intent = Intent(ACTION_USB_PERMISSION).apply {
|
||||
setPackage(context.packageName)
|
||||
}
|
||||
val pending = PendingIntent.getBroadcast(context, 0, intent, flags)
|
||||
manager.requestPermission(driver.device, pending)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true" android:color="#ffffff" />
|
||||
<item android:color="@color/purple_accent" />
|
||||
</selector>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true">
|
||||
<layer-list>
|
||||
<item android:left="-6dp" android:top="-6dp" android:right="-6dp" android:bottom="-6dp">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#4064ffda" />
|
||||
<corners android:radius="44dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#64ffda" />
|
||||
<corners android:radius="36dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
</item>
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#3dccb0" />
|
||||
<corners android:radius="36dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/teal_accent" />
|
||||
<corners android:radius="36dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true">
|
||||
<layer-list>
|
||||
<item android:left="-6dp" android:top="-6dp" android:right="-6dp" android:bottom="-6dp">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#40bb86fc" />
|
||||
<corners android:radius="44dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#7c4dff" />
|
||||
<corners android:radius="36dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
</item>
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#9966d4" />
|
||||
<corners android:radius="36dp" />
|
||||
<stroke android:width="2dp" android:color="@color/purple_accent" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#1Abb86fc" />
|
||||
<corners android:radius="36dp" />
|
||||
<stroke android:width="2dp" android:color="@color/purple_accent" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<color android:color="@color/bg_navy" />
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:gradientRadius="900dp"
|
||||
android:centerX="0.5"
|
||||
android:centerY="-0.2"
|
||||
android:startColor="#1A64ffda"
|
||||
android:endColor="#000d1117" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:gradientRadius="600dp"
|
||||
android:centerX="1.1"
|
||||
android:centerY="1.2"
|
||||
android:startColor="#12bb86fc"
|
||||
android:endColor="#000d1117" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:left="-8dp" android:top="-8dp" android:right="-8dp" android:bottom="-8dp">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#2064ffda" />
|
||||
<corners android:radius="24dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#ffffff" />
|
||||
<corners android:radius="16dp" />
|
||||
<stroke android:width="3dp" android:color="@color/teal_accent" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/green_status" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/bg_surface_elevated" />
|
||||
<corners android:radius="12dp" />
|
||||
<stroke android:width="1dp" android:color="#2264ffda" />
|
||||
</shape>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Background circle -->
|
||||
<path
|
||||
android:fillColor="#0d1117"
|
||||
android:pathData="M54,54m-50,0a50,50 0,1 1,100 0a50,50 0,1 1,-100 0" />
|
||||
<!-- Border ring -->
|
||||
<path
|
||||
android:strokeColor="#2264ffda"
|
||||
android:strokeWidth="1.5"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M54,54m-48,0a48,48 0,1 1,96 0a48,48 0,1 1,-96 0" />
|
||||
<!-- TV body -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M26,32 L82,32 Q86,32 86,36 L86,68 Q86,72 82,72 L26,72 Q22,72 22,68 L22,36 Q22,32 26,32 Z" />
|
||||
<!-- TV screen -->
|
||||
<path
|
||||
android:fillColor="#161b22"
|
||||
android:pathData="M28,35 L80,35 Q82,35 82,37 L82,66 Q82,68 80,68 L28,68 Q26,68 26,66 L26,37 Q26,35 28,35 Z" />
|
||||
<!-- LED glow - top (teal) -->
|
||||
<path
|
||||
android:fillColor="#64ffda"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
|
||||
<!-- LED glow - left (purple) -->
|
||||
<path
|
||||
android:fillColor="#bb86fc"
|
||||
android:fillAlpha="0.6"
|
||||
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
|
||||
<!-- LED glow - right (red) -->
|
||||
<path
|
||||
android:fillColor="#ff6b6b"
|
||||
android:fillAlpha="0.6"
|
||||
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
|
||||
<!-- LED glow - bottom (yellow) -->
|
||||
<path
|
||||
android:fillColor="#ffd93d"
|
||||
android:fillAlpha="0.6"
|
||||
android:pathData="M30,74 L78,74 L78,76 L30,76 Z" />
|
||||
<!-- TV stand -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M44,72 L44,78 L64,78 L64,72" />
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M38,78 L70,78 L70,80 L38,80 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Adaptive icon foreground: TV with LED glow strips.
|
||||
Centered in the 108dp safe zone (inner 72dp is guaranteed visible). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- TV body -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M26,32 L82,32 Q86,32 86,36 L86,68 Q86,72 82,72 L26,72 Q22,72 22,68 L22,36 Q22,32 26,32 Z" />
|
||||
<!-- TV screen -->
|
||||
<path
|
||||
android:fillColor="#161b22"
|
||||
android:pathData="M28,35 L80,35 Q82,35 82,37 L82,66 Q82,68 80,68 L28,68 Q26,68 26,66 L26,37 Q26,35 28,35 Z" />
|
||||
<!-- LED glow - top (teal) -->
|
||||
<path
|
||||
android:fillColor="#64ffda"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
|
||||
<!-- LED glow - left (purple) -->
|
||||
<path
|
||||
android:fillColor="#bb86fc"
|
||||
android:fillAlpha="0.6"
|
||||
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
|
||||
<!-- LED glow - right (red) -->
|
||||
<path
|
||||
android:fillColor="#ff6b6b"
|
||||
android:fillAlpha="0.6"
|
||||
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
|
||||
<!-- LED glow - bottom (yellow) -->
|
||||
<path
|
||||
android:fillColor="#ffd93d"
|
||||
android:fillAlpha="0.6"
|
||||
android:pathData="M30,74 L78,74 L78,76 L30,76 Z" />
|
||||
<!-- TV stand -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M44,72 L44,78 L64,78 L64,72" />
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M38,78 L70,78 L70,80 L38,80 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,191 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_main">
|
||||
|
||||
<!-- STOPPED STATE -->
|
||||
<LinearLayout
|
||||
android:id="@+id/stopped_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:paddingStart="160dp"
|
||||
android:paddingEnd="160dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="72dp"
|
||||
android:src="@drawable/ic_launcher"
|
||||
android:contentDescription="@null"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_name"
|
||||
android:textColor="@color/teal_accent"
|
||||
android:textSize="64sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.08"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:fontFamily="sans-serif-light" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/tagline"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="28sp"
|
||||
android:layout_marginBottom="64dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/toggle_button"
|
||||
style="@style/Widget.LedGrab.Button.Primary"
|
||||
android:layout_width="320dp"
|
||||
android:layout_height="72dp"
|
||||
android:text="@string/btn_start"
|
||||
android:textSize="22sp"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/autostart_check"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="32dp"
|
||||
android:text="@string/autostart_label"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="20sp"
|
||||
android:buttonTint="@color/teal_accent"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Version at bottom -->
|
||||
<TextView
|
||||
android:id="@+id/version_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|center_horizontal"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:textColor="@color/text_hint"
|
||||
android:textSize="18sp"
|
||||
tools:text="v0.1.0" />
|
||||
|
||||
<!-- RUNNING STATE -->
|
||||
<LinearLayout
|
||||
android:id="@+id/running_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="120dp"
|
||||
android:paddingEnd="120dp"
|
||||
android:paddingTop="80dp"
|
||||
android:paddingBottom="80dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<!-- Left: status + URL + stop -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="start|center_vertical"
|
||||
android:paddingEnd="64dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="32dp">
|
||||
|
||||
<View
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:background="@drawable/bg_status_dot"
|
||||
android:layout_marginEnd="16dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/status_running"
|
||||
android:textColor="@color/green_status"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.05" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/label_web_ui"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="22sp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/url_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/teal_accent"
|
||||
android:textSize="30sp"
|
||||
android:maxLines="1"
|
||||
android:textStyle="bold"
|
||||
android:background="@drawable/bg_url_chip"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingEnd="24dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:layout_marginBottom="56dp"
|
||||
tools:text="http://192.168.1.5:8080" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/stop_button_running"
|
||||
style="@style/Widget.LedGrab.Button.Secondary"
|
||||
android:layout_width="240dp"
|
||||
android:layout_height="64dp"
|
||||
android:text="@string/btn_stop"
|
||||
android:textSize="20sp"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Right: QR code -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_qr_container"
|
||||
android:padding="20dp"
|
||||
android:layout_marginBottom="20dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/qr_image"
|
||||
android:layout_width="280dp"
|
||||
android:layout_height="280dp"
|
||||
android:contentDescription="@string/qr_description"
|
||||
android:scaleType="fitXY" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/scan_to_configure"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="22sp"
|
||||
android:gravity="center" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/bg_navy" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">LedGrab</string>
|
||||
<string name="tagline">Фоновая подсветка для телевизора</string>
|
||||
<string name="btn_start">Начать захват</string>
|
||||
<string name="btn_stop">Стоп</string>
|
||||
<string name="status_running">Работает</string>
|
||||
<string name="label_web_ui">Адрес веб-интерфейса</string>
|
||||
<string name="scan_to_configure">Сканируйте для настройки</string>
|
||||
<string name="qr_description">QR-код для веб-интерфейса</string>
|
||||
<string name="version_prefix">v%1$s</string>
|
||||
<string name="autostart_label">Запускать при загрузке (только с root)</string>
|
||||
<string name="autostart_unavailable">Запуск при загрузке — недоступно (нужен root)</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">LedGrab</string>
|
||||
<string name="tagline">电视氛围灯光</string>
|
||||
<string name="btn_start">开始捕获</string>
|
||||
<string name="btn_stop">停止</string>
|
||||
<string name="status_running">运行中</string>
|
||||
<string name="label_web_ui">Web界面地址</string>
|
||||
<string name="scan_to_configure">扫码配置</string>
|
||||
<string name="qr_description">Web界面二维码</string>
|
||||
<string name="version_prefix">v%1$s</string>
|
||||
<string name="autostart_label">开机自启(仅限 root)</string>
|
||||
<string name="autostart_unavailable">开机自启 — 不可用(需要 root)</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="bg_navy">#0d1117</color>
|
||||
<color name="bg_navy_mid">#111827</color>
|
||||
<color name="bg_surface">#161b22</color>
|
||||
<color name="bg_surface_elevated">#1c2333</color>
|
||||
<color name="teal_accent">#64ffda</color>
|
||||
<color name="teal_accent_dim">#3399aa</color>
|
||||
<color name="teal_accent_alpha30">#4D64ffda</color>
|
||||
<color name="teal_accent_alpha15">#2664ffda</color>
|
||||
<color name="purple_accent">#bb86fc</color>
|
||||
<color name="purple_accent_dim">#7c4dff</color>
|
||||
<color name="purple_accent_alpha30">#4Dbb86fc</color>
|
||||
<color name="purple_accent_alpha15">#26bb86fc</color>
|
||||
<color name="green_status">#4caf50</color>
|
||||
<color name="green_status_dim">#1b5e20</color>
|
||||
<color name="text_primary">#e6edf3</color>
|
||||
<color name="text_secondary">#8b949e</color>
|
||||
<color name="text_hint">#484f58</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">LedGrab</string>
|
||||
<string name="tagline">Ambient lighting for your TV</string>
|
||||
<string name="btn_start">Start Capture</string>
|
||||
<string name="btn_stop">Stop</string>
|
||||
<string name="status_running">Running</string>
|
||||
<string name="label_web_ui">Web UI address</string>
|
||||
<string name="scan_to_configure">Scan to configure</string>
|
||||
<string name="qr_description">QR code for web UI</string>
|
||||
<string name="version_prefix">v%1$s</string>
|
||||
<string name="autostart_label">Start on boot (root only)</string>
|
||||
<string name="autostart_unavailable">Start on boot — unavailable (root required)</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.LedGrab" parent="@style/Theme.Leanback">
|
||||
<item name="android:windowBackground">@color/bg_navy</item>
|
||||
<item name="android:colorBackground">@color/bg_navy</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:textColorPrimary">@color/text_primary</item>
|
||||
<item name="android:textColorSecondary">@color/text_secondary</item>
|
||||
<item name="android:textColorHint">@color/text_hint</item>
|
||||
<item name="android:colorAccent">@color/teal_accent</item>
|
||||
<item name="android:colorControlHighlight">@color/teal_accent_dim</item>
|
||||
<item name="android:colorControlActivated">@color/teal_accent</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.LedGrab.Button.Primary" parent="@android:style/Widget.Button">
|
||||
<item name="android:background">@drawable/bg_button_primary</item>
|
||||
<item name="android:textColor">@color/bg_navy</item>
|
||||
<item name="android:textStyle">bold</item>
|
||||
<item name="android:focusable">true</item>
|
||||
<item name="android:stateListAnimator">@null</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.LedGrab.Button.Secondary" parent="@android:style/Widget.Button">
|
||||
<item name="android:background">@drawable/bg_button_secondary</item>
|
||||
<item name="android:textColor">@color/btn_secondary_text</item>
|
||||
<item name="android:textStyle">bold</item>
|
||||
<item name="android:focusable">true</item>
|
||||
<item name="android:stateListAnimator">@null</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
LedGrab communicates with WLED controllers, Home Assistant, and MQTT
|
||||
brokers on the local network via plain HTTP/UDP. Cleartext traffic
|
||||
must be allowed for these connections to work on Android 9+.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Cross-compile pydantic-core for Android across all three ABIs:
|
||||
# arm64-v8a (primary — real TV hardware)
|
||||
# x86_64 (modern emulators)
|
||||
# x86 (legacy emulators)
|
||||
#
|
||||
# Outputs wheels into android/wheels/. Wheels are linked against the real
|
||||
# libpython3.11.so shipped by Chaquopy (stub .so does NOT work — see
|
||||
# memory/project_android_app.md for the incident notes).
|
||||
#
|
||||
# Prerequisites (on host):
|
||||
# - Rust + cargo (rustup) with targets: aarch64/x86_64/i686-linux-android
|
||||
# - Android NDK (ANDROID_NDK_HOME, or installed at Sdk/ndk/*)
|
||||
# - Python 3.11 (matches Chaquopy's embedded version)
|
||||
# - maturin (pip install maturin)
|
||||
#
|
||||
# Rebuild cadence: whenever PYDANTIC_CORE_VERSION changes, or pydantic's
|
||||
# core dependency version changes.
|
||||
#
|
||||
# Usage:
|
||||
# ./build-pydantic-core.sh # build all three ABIs
|
||||
# ./build-pydantic-core.sh arm64 # build a single ABI
|
||||
# ./build-pydantic-core.sh arm64 x86_64 # build a subset
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ANDROID_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
WHEELS_DIR="$ANDROID_DIR/wheels"
|
||||
BUILD_DIR="$ANDROID_DIR/.build-cache"
|
||||
|
||||
PYDANTIC_CORE_VERSION="2.46.0"
|
||||
API_LEVEL=24
|
||||
PY_VERSION="3.11"
|
||||
|
||||
# Resolve a concrete python3.11 interpreter path (maturin needs it callable).
|
||||
# Windows Git Bash doesn't ship `python3.11` on PATH; fall back to `py -3.11`.
|
||||
if [ -z "${PYTHON_BIN:-}" ]; then
|
||||
# Prefer `py -3.11` on Windows (Git Bash's `python3.11` resolves to a
|
||||
# non-functional MSStore stub). Fall back to `python3.11` on Linux/macOS.
|
||||
if command -v py >/dev/null 2>&1 && py -3.11 -c '' 2>/dev/null; then
|
||||
PYTHON_BIN="$(py -3.11 -c 'import sys; print(sys.executable)' 2>/dev/null | tr -d '\r')"
|
||||
elif command -v python3.11 >/dev/null 2>&1 && python3.11 -c '' 2>/dev/null; then
|
||||
PYTHON_BIN="$(command -v python3.11)"
|
||||
fi
|
||||
fi
|
||||
[ -n "${PYTHON_BIN:-}" ] || { echo "ERROR: python3.11 not found; set PYTHON_BIN"; exit 1; }
|
||||
echo "Python: $PYTHON_BIN"
|
||||
|
||||
mkdir -p "$WHEELS_DIR"
|
||||
|
||||
# ── Find Android NDK ────────────────────────────────────────────────
|
||||
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
|
||||
for candidate in \
|
||||
"$HOME/Library/Android/sdk/ndk"/* \
|
||||
"$HOME/Android/Sdk/ndk"/* \
|
||||
"${LOCALAPPDATA:-}/Android/Sdk/ndk"/* \
|
||||
"/c/Users/$(whoami)/AppData/Local/Android/Sdk/ndk"/* \
|
||||
"/usr/local/lib/android/sdk/ndk"/* \
|
||||
"${ANDROID_SDK_ROOT:-}/ndk"/*; do
|
||||
if [ -d "$candidate" ]; then
|
||||
ANDROID_NDK_HOME="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
[ -n "${ANDROID_NDK_HOME:-}" ] || { echo "ERROR: NDK not found; set ANDROID_NDK_HOME"; exit 1; }
|
||||
echo "NDK: $ANDROID_NDK_HOME"
|
||||
|
||||
case "$(uname -s)" in
|
||||
Linux*) HOST_TAG="linux-x86_64" ;;
|
||||
Darwin*) HOST_TAG="darwin-x86_64" ;;
|
||||
MINGW*|MSYS*|CYGWIN*) HOST_TAG="windows-x86_64" ;;
|
||||
*) echo "Unsupported host OS"; exit 1 ;;
|
||||
esac
|
||||
TOOLCHAIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/$HOST_TAG"
|
||||
READELF="$TOOLCHAIN/bin/llvm-readelf"
|
||||
|
||||
# ── Prepare source tree ─────────────────────────────────────────────
|
||||
SRC_DIR="$BUILD_DIR/pydantic_core-$PYDANTIC_CORE_VERSION"
|
||||
if [ ! -d "$SRC_DIR" ]; then
|
||||
echo "Downloading pydantic_core $PYDANTIC_CORE_VERSION sdist..."
|
||||
mkdir -p "$BUILD_DIR"
|
||||
SDIST="$BUILD_DIR/pydantic_core-$PYDANTIC_CORE_VERSION.tar.gz"
|
||||
[ -f "$SDIST" ] || curl -sSL --retry 3 -o "$SDIST" \
|
||||
"https://files.pythonhosted.org/packages/source/p/pydantic_core/pydantic_core-$PYDANTIC_CORE_VERSION.tar.gz"
|
||||
tar -xzf "$SDIST" -C "$BUILD_DIR"
|
||||
fi
|
||||
|
||||
# ── ABI table ───────────────────────────────────────────────────────
|
||||
# Columns: short_name rust_target clang_prefix sysconfig_dir
|
||||
ABI_TABLE=(
|
||||
"arm64 aarch64-linux-android aarch64-linux-android${API_LEVEL} cross-sysconfig"
|
||||
"x86_64 x86_64-linux-android x86_64-linux-android${API_LEVEL} cross-sysconfig-x86_64"
|
||||
"x86 i686-linux-android i686-linux-android${API_LEVEL} cross-sysconfig-x86"
|
||||
)
|
||||
|
||||
declare -A ABI_TAG_MAP=(
|
||||
[arm64]="arm64_v8a"
|
||||
[x86_64]="x86_64"
|
||||
[x86]="x86"
|
||||
)
|
||||
|
||||
# ── Select which ABIs to build ──────────────────────────────────────
|
||||
SELECTED=("$@")
|
||||
if [ ${#SELECTED[@]} -eq 0 ]; then
|
||||
SELECTED=(arm64 x86_64 x86)
|
||||
fi
|
||||
|
||||
# ── Ensure rust targets are installed ───────────────────────────────
|
||||
for name in "${SELECTED[@]}"; do
|
||||
for row in "${ABI_TABLE[@]}"; do
|
||||
read -r sname rtarget _ _ <<<"$row"
|
||||
if [ "$sname" = "$name" ]; then
|
||||
rustup target add "$rtarget" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# ── Build loop ──────────────────────────────────────────────────────
|
||||
cd "$SRC_DIR"
|
||||
|
||||
for name in "${SELECTED[@]}"; do
|
||||
MATCHED=""
|
||||
for row in "${ABI_TABLE[@]}"; do
|
||||
read -r sname rtarget cprefix sysdir <<<"$row"
|
||||
if [ "$sname" = "$name" ]; then
|
||||
MATCHED="1"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ -n "$MATCHED" ] || { echo "Unknown ABI: $name"; exit 1; }
|
||||
|
||||
CROSS_LIB_DIR="$BUILD_DIR/$sysdir"
|
||||
[ -f "$CROSS_LIB_DIR/libpython3.11.so" ] || {
|
||||
echo "ERROR: missing $CROSS_LIB_DIR/libpython3.11.so (extract from a Chaquopy APK)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# On Windows hosts the clang wrapper is a .cmd batch file; cargo/rustc
|
||||
# can't exec it as a linker directly (os error 193). Use the .cmd path
|
||||
# explicitly so CreateProcess picks up cmd.exe as interpreter.
|
||||
CC_BIN="$TOOLCHAIN/bin/${cprefix}-clang"
|
||||
if [ "$HOST_TAG" = "windows-x86_64" ] && [ -f "${CC_BIN}.cmd" ]; then
|
||||
CC_BIN="${CC_BIN}.cmd"
|
||||
fi
|
||||
AR_BIN="$TOOLCHAIN/bin/llvm-ar"
|
||||
[ "$HOST_TAG" = "windows-x86_64" ] && [ -f "${AR_BIN}.exe" ] && AR_BIN="${AR_BIN}.exe"
|
||||
[ -f "$CC_BIN" ] || { echo "ERROR: $CC_BIN not found"; exit 1; }
|
||||
|
||||
# Normalize rust target name for env var (cargo wants UPPER_WITH_UNDERSCORES)
|
||||
TARGET_ENV=$(echo "$rtarget" | tr 'a-z-' 'A-Z_')
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " Building pydantic_core $PYDANTIC_CORE_VERSION for $name ($rtarget)"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
|
||||
env \
|
||||
CARGO_TARGET_DIR="$SRC_DIR/target" \
|
||||
"CC_${rtarget//-/_}=$CC_BIN" \
|
||||
"AR_${rtarget//-/_}=$AR_BIN" \
|
||||
"CARGO_TARGET_${TARGET_ENV}_LINKER=$CC_BIN" \
|
||||
PYO3_CROSS=1 \
|
||||
PYO3_CROSS_LIB_DIR="$CROSS_LIB_DIR" \
|
||||
PYO3_CROSS_PYTHON_VERSION="$PY_VERSION" \
|
||||
RUSTFLAGS="-C link-arg=-Wl,--no-as-needed -C link-arg=-lpython3.11 -C link-arg=-L$CROSS_LIB_DIR" \
|
||||
maturin build \
|
||||
--release \
|
||||
--target "$rtarget" \
|
||||
--interpreter "$PYTHON_BIN" \
|
||||
--out "$WHEELS_DIR" \
|
||||
--compatibility linux
|
||||
|
||||
# maturin writes the wheel with the correct android_<api>_<abi> platform
|
||||
# tag directly (the sysconfigdata provides MULTIARCH). Find + verify.
|
||||
ABI_TAG="${ABI_TAG_MAP[$name]}"
|
||||
OUT_WHL="$WHEELS_DIR/pydantic_core-$PYDANTIC_CORE_VERSION-cp311-cp311-android_${API_LEVEL}_${ABI_TAG}.whl"
|
||||
[ -f "$OUT_WHL" ] || { echo "ERROR: expected wheel not found: $OUT_WHL"; exit 1; }
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
unzip -qo "$OUT_WHL" -d "$TMP" "pydantic_core/*.so"
|
||||
SO=$(find "$TMP" -name "*.so" | head -1)
|
||||
if "$READELF" -d "$SO" | grep -q "libpython3.11.so"; then
|
||||
echo " NEEDED OK: libpython3.11.so present in $(basename "$OUT_WHL")"
|
||||
else
|
||||
echo " ERROR: libpython3.11.so NOT in NEEDED — wheel will crash at import"
|
||||
"$READELF" -d "$SO" | grep NEEDED
|
||||
rm -rf "$TMP"
|
||||
exit 1
|
||||
fi
|
||||
rm -rf "$TMP"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Build complete ==="
|
||||
ls -lh "$WHEELS_DIR"/pydantic_core-*.whl
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Set up the cross-compilation environment for building Python native
|
||||
# extensions targeting Android ARM64.
|
||||
#
|
||||
# This script:
|
||||
# 1. Verifies Android NDK is installed
|
||||
# 2. Installs the Rust aarch64-linux-android target
|
||||
# 3. Installs maturin (Python wheel builder for Rust extensions)
|
||||
# 4. Runs a quick test compile to verify the toolchain works
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== LedGrab Android NDK Setup ==="
|
||||
|
||||
# ── Check prerequisites ─────────────────────────────────────────────
|
||||
|
||||
if ! command -v rustc &>/dev/null; then
|
||||
echo "ERROR: Rust is not installed."
|
||||
echo "Install from: https://rustup.rs/"
|
||||
exit 1
|
||||
fi
|
||||
echo "Rust: $(rustc --version)"
|
||||
|
||||
if ! command -v cargo &>/dev/null; then
|
||||
echo "ERROR: Cargo is not installed."
|
||||
exit 1
|
||||
fi
|
||||
echo "Cargo: $(cargo --version)"
|
||||
|
||||
if ! command -v python3.11 &>/dev/null && ! command -v python3 &>/dev/null; then
|
||||
echo "WARNING: Python 3.11 not found. Needed for maturin builds."
|
||||
fi
|
||||
|
||||
# ── Install Rust target ─────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "Installing Rust Android target..."
|
||||
rustup target add aarch64-linux-android
|
||||
echo "Installed targets:"
|
||||
rustup target list --installed | grep android
|
||||
|
||||
# ── Install maturin ─────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "Installing maturin..."
|
||||
pip install maturin 2>/dev/null || pip3 install maturin 2>/dev/null || {
|
||||
echo "WARNING: Could not install maturin. Install manually: pip install maturin"
|
||||
}
|
||||
|
||||
if command -v maturin &>/dev/null; then
|
||||
echo "maturin: $(maturin --version)"
|
||||
fi
|
||||
|
||||
# ── Verify NDK ──────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
if [ -n "${ANDROID_NDK_HOME:-}" ]; then
|
||||
echo "ANDROID_NDK_HOME: $ANDROID_NDK_HOME"
|
||||
else
|
||||
echo "ANDROID_NDK_HOME is not set."
|
||||
echo "Set it to your NDK installation path, e.g.:"
|
||||
echo " export ANDROID_NDK_HOME=\$HOME/Android/Sdk/ndk/26.1.10909125"
|
||||
echo ""
|
||||
echo "Or install NDK via Android Studio:"
|
||||
echo " SDK Manager → SDK Tools → NDK (Side by side)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Setup complete ==="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Ensure ANDROID_NDK_HOME is set"
|
||||
echo " 2. Run: ./build-pydantic-core.sh"
|
||||
@@ -0,0 +1,5 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.9.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
id("com.chaquo.python") version "17.0.0" apply false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
org.gradle.parallel=false
|
||||
org.gradle.configuration-cache=false
|
||||
org.gradle.unsafe.isolated-projects=false
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
Vendored
+252
@@ -0,0 +1,252 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+94
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,20 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
// usb-serial-for-android (mik3y) is distributed via JitPack
|
||||
maven { url = uri("https://jitpack.io") }
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "LedGrab"
|
||||
include(":app")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
-140
@@ -1,140 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Shared build functions for LedGrab distribution packaging.
|
||||
# Sourced by build-dist.sh (Linux) and build-dist-windows.sh (Windows).
|
||||
#
|
||||
# Expected variables set by the caller before sourcing:
|
||||
# SCRIPT_DIR, BUILD_DIR, DIST_DIR, SERVER_DIR, APP_DIR
|
||||
|
||||
# ── Version detection ────────────────────────────────────────
|
||||
|
||||
detect_version() {
|
||||
# Usage: detect_version [explicit_version]
|
||||
local version="${1:-}"
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
version=$(git describe --tags --exact-match 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$version" ]; then
|
||||
version="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
|
||||
fi
|
||||
if [ -z "$version" ]; then
|
||||
version=$(grep -oP '^version\s*=\s*"\K[^"]+' "$SERVER_DIR/pyproject.toml" 2>/dev/null || echo "0.0.0")
|
||||
fi
|
||||
|
||||
VERSION_CLEAN="${version#v}"
|
||||
|
||||
# Stamp the resolved version into pyproject.toml so that
|
||||
# importlib.metadata reads the correct value at runtime.
|
||||
sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" "$SERVER_DIR/pyproject.toml"
|
||||
}
|
||||
|
||||
# ── Clean previous build ─────────────────────────────────────
|
||||
|
||||
clean_dist() {
|
||||
if [ -d "$DIST_DIR" ]; then
|
||||
echo " Cleaning previous build..."
|
||||
rm -rf "$DIST_DIR"
|
||||
fi
|
||||
mkdir -p "$DIST_DIR"
|
||||
}
|
||||
|
||||
# ── Build frontend ───────────────────────────────────────────
|
||||
|
||||
build_frontend() {
|
||||
echo " Building frontend bundle..."
|
||||
(cd "$SERVER_DIR" && npm ci --loglevel error && npm run build) 2>&1 | {
|
||||
grep -v 'RemoteException' || true
|
||||
}
|
||||
}
|
||||
|
||||
# ── Copy application files ───────────────────────────────────
|
||||
|
||||
copy_app_files() {
|
||||
echo " Copying application files..."
|
||||
mkdir -p "$APP_DIR"
|
||||
|
||||
cp -r "$SERVER_DIR/src" "$APP_DIR/src"
|
||||
cp -r "$SERVER_DIR/config" "$APP_DIR/config"
|
||||
mkdir -p "$DIST_DIR/data" "$DIST_DIR/logs"
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
# ── Site-packages cleanup ────────────────────────────────────
|
||||
#
|
||||
# Strips tests, type stubs, unused submodules, and debug symbols
|
||||
# from the installed site-packages directory.
|
||||
#
|
||||
# Args:
|
||||
# $1 — path to site-packages directory
|
||||
# $2 — native extension suffix: "pyd" (Windows) or "so" (Linux)
|
||||
# $3 — native lib suffix for OpenCV ffmpeg: "dll" or "so"
|
||||
|
||||
cleanup_site_packages() {
|
||||
local sp_dir="$1"
|
||||
local ext_suffix="${2:-so}"
|
||||
local lib_suffix="${3:-so}"
|
||||
|
||||
echo " Cleaning up site-packages to reduce size..."
|
||||
|
||||
# ── Generic cleanup ──────────────────────────────────────
|
||||
find "$sp_dir" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$sp_dir" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$sp_dir" -type d -name test -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$sp_dir" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$sp_dir" -name "*.pyi" -delete 2>/dev/null || true
|
||||
|
||||
# ── pip / setuptools (not needed at runtime) ─────────────
|
||||
rm -rf "$sp_dir"/pip "$sp_dir"/pip-* 2>/dev/null || true
|
||||
rm -rf "$sp_dir"/setuptools "$sp_dir"/setuptools-* "$sp_dir"/pkg_resources 2>/dev/null || true
|
||||
rm -rf "$sp_dir"/_distutils_hack 2>/dev/null || true
|
||||
|
||||
# ── OpenCV ───────────────────────────────────────────────
|
||||
local cv2_dir="$sp_dir/cv2"
|
||||
if [ -d "$cv2_dir" ]; then
|
||||
# Remove ffmpeg (28 MB on Windows), Haar cascades, dev files
|
||||
rm -f "$cv2_dir"/opencv_videoio_ffmpeg*."$lib_suffix" 2>/dev/null || true
|
||||
rm -rf "$cv2_dir/data" "$cv2_dir/gapi" "$cv2_dir/misc" "$cv2_dir/utils" 2>/dev/null || true
|
||||
rm -rf "$cv2_dir/typing_stubs" "$cv2_dir/typing" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── NumPy ────────────────────────────────────────────────
|
||||
# Remove unused submodules (only core, fft, random are used)
|
||||
for mod in polynomial linalg ma lib distutils f2py typing _pyinstaller; do
|
||||
rm -rf "$sp_dir/numpy/$mod" 2>/dev/null || true
|
||||
done
|
||||
rm -rf "$sp_dir/numpy/tests" "$sp_dir/numpy/*/tests" 2>/dev/null || true
|
||||
|
||||
# ── Pillow (only used for system tray icon) ──────────────
|
||||
rm -rf "$sp_dir/PIL/tests" 2>/dev/null || true
|
||||
# Remove unused image format plugins (keep JPEG, PNG, ICO, BMP)
|
||||
for plugin in Eps Gif Tiff Webp Psd Pcx Xbm Xpm Dds Ftex Gbr Grib \
|
||||
Icns Im Imt Iptc McIrdas Mpo Msp Pcd Pixar Ppm Sgi \
|
||||
Spider Sun Tga Wal Wmf; do
|
||||
rm -f "$sp_dir/PIL/${plugin}ImagePlugin.py" 2>/dev/null || true
|
||||
rm -f "$sp_dir/PIL/${plugin}ImagePlugin.pyc" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# ── zeroconf ─────────────────────────────────────────────
|
||||
rm -rf "$sp_dir/zeroconf/_services" 2>/dev/null || true
|
||||
|
||||
# ── Strip debug symbols ──────────────────────────────────
|
||||
if command -v strip &>/dev/null; then
|
||||
echo " Stripping debug symbols from .$ext_suffix files..."
|
||||
find "$sp_dir" -name "*.$ext_suffix" -exec strip --strip-debug {} \; 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── Remove .py source (keep .pyc bytecode) ───────────────
|
||||
echo " Removing .py source from site-packages (keeping .pyc)..."
|
||||
find "$sp_dir" -name "*.py" ! -name "__init__.py" -delete 2>/dev/null || true
|
||||
|
||||
# ── Remove wled_controller if pip-installed ───────────────
|
||||
rm -rf "$sp_dir"/wled_controller* "$sp_dir"/wled*.dist-info 2>/dev/null || true
|
||||
|
||||
local cleaned_size
|
||||
cleaned_size=$(du -sh "$sp_dir" | cut -f1)
|
||||
echo " Site-packages after cleanup: $cleaned_size"
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Shared build functions for LedGrab distribution packaging.
|
||||
# Sourced by build-dist.sh (Linux) and build-dist-windows.sh (Windows).
|
||||
#
|
||||
# Expected variables set by the caller before sourcing:
|
||||
# SCRIPT_DIR, BUILD_DIR, DIST_DIR, SERVER_DIR, APP_DIR
|
||||
|
||||
# ── Version detection ────────────────────────────────────────
|
||||
|
||||
detect_version() {
|
||||
# Usage: detect_version [explicit_version]
|
||||
local version="${1:-}"
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
version=$(git describe --tags --exact-match 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$version" ]; then
|
||||
version="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
|
||||
fi
|
||||
if [ -z "$version" ]; then
|
||||
version=$(grep -oP '^version\s*=\s*"\K[^"]+' "$SERVER_DIR/pyproject.toml" 2>/dev/null || echo "0.0.0")
|
||||
fi
|
||||
|
||||
VERSION_CLEAN="${version#v}"
|
||||
|
||||
# Normalize non-PEP440 version labels (e.g. "dev", "nightly", "snapshot")
|
||||
# to a valid PEP440 dev release. Without this, pip/setuptools rejects the
|
||||
# pyproject.toml with: `project.version` must be pep440.
|
||||
if ! [[ "$VERSION_CLEAN" =~ ^[0-9]+(\.[0-9]+)*((a|b|rc|\.dev|\.post)[0-9]+)*(\+[a-zA-Z0-9.]+)?$ ]]; then
|
||||
echo " Warning: '$VERSION_CLEAN' is not PEP440-compliant, using 0.0.0.dev0"
|
||||
VERSION_CLEAN="0.0.0.dev0"
|
||||
fi
|
||||
|
||||
# Stamp the resolved version into pyproject.toml so that
|
||||
# importlib.metadata reads the correct value at runtime.
|
||||
sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" "$SERVER_DIR/pyproject.toml"
|
||||
}
|
||||
|
||||
# ── Clean previous build ─────────────────────────────────────
|
||||
|
||||
clean_dist() {
|
||||
if [ -d "$DIST_DIR" ]; then
|
||||
echo " Cleaning previous build..."
|
||||
rm -rf "$DIST_DIR"
|
||||
fi
|
||||
mkdir -p "$DIST_DIR"
|
||||
}
|
||||
|
||||
# ── Build frontend ───────────────────────────────────────────
|
||||
|
||||
build_frontend() {
|
||||
echo " Building frontend bundle..."
|
||||
(cd "$SERVER_DIR" && npm ci --loglevel error && npm run build) 2>&1 | {
|
||||
grep -v 'RemoteException' || true
|
||||
}
|
||||
}
|
||||
|
||||
# ── Copy application files ───────────────────────────────────
|
||||
|
||||
copy_app_files() {
|
||||
echo " Copying application files..."
|
||||
mkdir -p "$APP_DIR"
|
||||
|
||||
cp -r "$SERVER_DIR/src" "$APP_DIR/src"
|
||||
cp -r "$SERVER_DIR/config" "$APP_DIR/config"
|
||||
mkdir -p "$DIST_DIR/data" "$DIST_DIR/logs"
|
||||
|
||||
# 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 ────────────────────────────────────
|
||||
#
|
||||
# Strips tests, type stubs, unused submodules, and debug symbols
|
||||
# from the installed site-packages directory.
|
||||
#
|
||||
# Args:
|
||||
# $1 — path to site-packages directory
|
||||
# $2 — native extension suffix: "pyd" (Windows) or "so" (Linux)
|
||||
# $3 — native lib suffix for OpenCV ffmpeg: "dll" or "so"
|
||||
|
||||
cleanup_site_packages() {
|
||||
local sp_dir="$1"
|
||||
local ext_suffix="${2:-so}"
|
||||
local lib_suffix="${3:-so}"
|
||||
|
||||
echo " Cleaning up site-packages to reduce size..."
|
||||
|
||||
# ── Generic cleanup ──────────────────────────────────────
|
||||
find "$sp_dir" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$sp_dir" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$sp_dir" -type d -name test -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$sp_dir" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$sp_dir" -name "*.pyi" -delete 2>/dev/null || true
|
||||
|
||||
# ── pip / setuptools (not needed at runtime) ─────────────
|
||||
rm -rf "$sp_dir"/pip "$sp_dir"/pip-* 2>/dev/null || true
|
||||
rm -rf "$sp_dir"/setuptools "$sp_dir"/setuptools-* "$sp_dir"/pkg_resources 2>/dev/null || true
|
||||
rm -rf "$sp_dir"/_distutils_hack 2>/dev/null || true
|
||||
|
||||
# ── OpenCV ───────────────────────────────────────────────
|
||||
local cv2_dir="$sp_dir/cv2"
|
||||
if [ -d "$cv2_dir" ]; then
|
||||
# Remove ffmpeg (28 MB on Windows), Haar cascades, dev files
|
||||
rm -f "$cv2_dir"/opencv_videoio_ffmpeg*."$lib_suffix" 2>/dev/null || true
|
||||
rm -rf "$cv2_dir/data" "$cv2_dir/gapi" "$cv2_dir/misc" "$cv2_dir/utils" 2>/dev/null || true
|
||||
rm -rf "$cv2_dir/typing_stubs" "$cv2_dir/typing" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── NumPy ────────────────────────────────────────────────
|
||||
# Only strip modules that are safely unused by numpy's own import chain.
|
||||
# DO NOT strip: lib, linalg, ma, matrixlib — numpy.__init__ imports them
|
||||
# transitively (e.g. matrixlib → defmatrix → linalg), so removing any of
|
||||
# these breaks `import numpy` itself, cascading into every downstream
|
||||
# module. Learned the hard way in the v0.0.0.dev0 Windows build.
|
||||
for mod in polynomial distutils f2py typing _pyinstaller; do
|
||||
rm -rf "$sp_dir/numpy/$mod" 2>/dev/null || true
|
||||
done
|
||||
rm -rf "$sp_dir/numpy/tests" "$sp_dir/numpy/*/tests" 2>/dev/null || true
|
||||
|
||||
# ── Pillow (only used for system tray icon) ──────────────
|
||||
rm -rf "$sp_dir/PIL/tests" 2>/dev/null || true
|
||||
# Remove unused image format plugins (keep JPEG, PNG, ICO, BMP)
|
||||
for plugin in Eps Gif Tiff Webp Psd Pcx Xbm Xpm Dds Ftex Gbr Grib \
|
||||
Icns Im Imt Iptc McIrdas Mpo Msp Pcd Pixar Ppm Sgi \
|
||||
Spider Sun Tga Wal Wmf; do
|
||||
rm -f "$sp_dir/PIL/${plugin}ImagePlugin.py" 2>/dev/null || true
|
||||
rm -f "$sp_dir/PIL/${plugin}ImagePlugin.pyc" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# ── zeroconf ─────────────────────────────────────────────
|
||||
# DO NOT strip zeroconf/_services — the compiled Cython _listener.pyd
|
||||
# imports from it, and the import fails at runtime with:
|
||||
# ModuleNotFoundError: No module named 'zeroconf._services'
|
||||
# Same class of bug as numpy — "presumed unused" submodule is actually
|
||||
# imported internally by the package's own compiled code.
|
||||
|
||||
# ── Strip debug symbols ──────────────────────────────────
|
||||
if command -v strip &>/dev/null; then
|
||||
echo " Stripping debug symbols from .$ext_suffix files..."
|
||||
find "$sp_dir" -name "*.$ext_suffix" -exec strip --strip-debug {} \; 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── Remove ledgrab if pip-installed ───────────────
|
||||
rm -rf "$sp_dir"/ledgrab* "$sp_dir"/ledgrab*.dist-info 2>/dev/null || true
|
||||
|
||||
local cleaned_size
|
||||
cleaned_size=$(du -sh "$sp_dir" | cut -f1)
|
||||
echo " Site-packages after cleanup: $cleaned_size"
|
||||
}
|
||||
|
||||
# ── Pre-compile .py → .pyc (keep sources) ────────────────────
|
||||
#
|
||||
# MUST run AFTER cleanup_site_packages. Speeds up first startup by
|
||||
# producing .pyc alongside .py. We deliberately do NOT delete .py
|
||||
# sources afterwards:
|
||||
#
|
||||
# 1. OpenCV's loader does literal file I/O on cv2/config.py (not
|
||||
# an import) — stripping it breaks `import cv2` with:
|
||||
# "OpenCV loader: missing configuration file: ['config.py']".
|
||||
# 2. Other packages may do similar tricks (inspect.getsource,
|
||||
# runtime introspection, __file__-relative data loading).
|
||||
# 3. The size saving (~30%) isn't worth the whack-a-mole of
|
||||
# shipping broken installers. We already hit this with
|
||||
# numpy.linalg and zeroconf._services — enough incidents.
|
||||
#
|
||||
# Args:
|
||||
# $1 — directory to compile (site-packages or app/src)
|
||||
# $2 — python executable to use (default: python3)
|
||||
|
||||
compile_and_strip_sources() {
|
||||
local target_dir="$1"
|
||||
local py_cmd="${2:-python3}"
|
||||
|
||||
if [ ! -d "$target_dir" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo " Pre-compiling Python bytecode in $(basename "$target_dir")..."
|
||||
"$py_cmd" -m compileall -b -q "$target_dir" 2>/dev/null || {
|
||||
echo " ERROR: compileall failed for $target_dir — aborting"
|
||||
return 1
|
||||
}
|
||||
# Drop __pycache__ to save the duplicated PEP-3147 copies; the
|
||||
# `-b` flag above placed legacy .pyc next to each .py, so nothing
|
||||
# of value is lost here.
|
||||
find "$target_dir" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ── Import smoke test ────────────────────────────────────────
|
||||
#
|
||||
# Verifies that every top-level dependency that ledgrab actually
|
||||
# uses can be imported from the stripped site-packages. Catches regressions
|
||||
# where cleanup_site_packages removes a submodule that turns out to be
|
||||
# imported internally by the package (e.g. numpy.linalg, zeroconf._services).
|
||||
# Failing here is cheap; failing on a user's machine after install is not.
|
||||
#
|
||||
# Args:
|
||||
# $1 — path to site-packages to test against
|
||||
# $2 — python executable
|
||||
# $3 — (optional) extra PYTHONPATH entry (e.g. app/src for ledgrab)
|
||||
|
||||
smoke_test_imports() {
|
||||
local sp_dir="$1"
|
||||
local py_cmd="${2:-python3}"
|
||||
local extra_path="${3:-}"
|
||||
|
||||
echo " Running import smoke test..."
|
||||
local pypath="$sp_dir"
|
||||
if [ -n "$extra_path" ]; then
|
||||
pypath="$extra_path:$sp_dir"
|
||||
fi
|
||||
|
||||
# Modules that MUST import cleanly IF PRESENT. We don't enforce
|
||||
# installation — Pillow for example is only a Windows dep. But if a
|
||||
# module's top-level package dir exists in site-packages and we
|
||||
# can't import it, that's a broken install and we abort.
|
||||
local smoke_script
|
||||
smoke_script=$(cat <<'PYEOF'
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
sp_dir = sys.argv[1]
|
||||
|
||||
# (module_name, site-packages path to check for presence)
|
||||
candidates = [
|
||||
('numpy', 'numpy'),
|
||||
('numpy.linalg', 'numpy/linalg'),
|
||||
('numpy.lib', 'numpy/lib'),
|
||||
('numpy.matrixlib', 'numpy/matrixlib'),
|
||||
('cv2', 'cv2'),
|
||||
('fastapi', 'fastapi'),
|
||||
('uvicorn', 'uvicorn'),
|
||||
('starlette', 'starlette'),
|
||||
('pydantic', 'pydantic'),
|
||||
('zeroconf', 'zeroconf'),
|
||||
('zeroconf._services', 'zeroconf/_services'),
|
||||
('PIL', 'PIL'),
|
||||
('PIL.Image', 'PIL'),
|
||||
('yaml', 'yaml'),
|
||||
]
|
||||
|
||||
tested = 0
|
||||
skipped = 0
|
||||
failed = []
|
||||
for mod, path in candidates:
|
||||
if not os.path.exists(os.path.join(sp_dir, path)):
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
importlib.import_module(mod)
|
||||
tested += 1
|
||||
except Exception as e:
|
||||
failed.append(f'{mod}: {type(e).__name__}: {e}')
|
||||
|
||||
if failed:
|
||||
print('SMOKE TEST FAILED:', file=sys.stderr)
|
||||
for f in failed:
|
||||
print(f' {f}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f' Smoke test passed ({tested} imported, {skipped} not installed)')
|
||||
PYEOF
|
||||
)
|
||||
|
||||
if ! PYTHONPATH="$pypath" "$py_cmd" -c "$smoke_script" "$sp_dir"; then
|
||||
echo " ERROR: smoke test failed — site-packages is broken, aborting build"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
@@ -14,10 +14,11 @@
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BUILD_DIR="$SCRIPT_DIR/build"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
BUILD_DIR="$SCRIPT_DIR"
|
||||
DIST_NAME="LedGrab"
|
||||
DIST_DIR="$BUILD_DIR/$DIST_NAME"
|
||||
SERVER_DIR="$SCRIPT_DIR/server"
|
||||
SERVER_DIR="$REPO_ROOT/server"
|
||||
PYTHON_DIR="$DIST_DIR/python"
|
||||
APP_DIR="$DIST_DIR/app"
|
||||
PYTHON_VERSION="${PYTHON_VERSION:-3.11.9}"
|
||||
@@ -66,7 +67,7 @@ if ! grep -q 'Lib\\site-packages' "$PTH_FILE"; then
|
||||
echo 'Lib\site-packages' >> "$PTH_FILE"
|
||||
fi
|
||||
# Embedded Python ._pth overrides PYTHONPATH, so we must add the app
|
||||
# source directory here for wled_controller to be importable
|
||||
# source directory here for ledgrab to be importable
|
||||
if ! grep -q '\.\./app/src' "$PTH_FILE"; then
|
||||
echo '../app/src' >> "$PTH_FILE"
|
||||
fi
|
||||
@@ -177,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"
|
||||
@@ -200,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
|
||||
@@ -213,6 +221,10 @@ WIN_DEPS=(
|
||||
# System tray (Pillow needed by pystray for tray icon)
|
||||
"pystray>=0.19.0"
|
||||
"Pillow>=10.4.0"
|
||||
# Windows screen capture engines (mss is the only fallback without these)
|
||||
"dxcam>=0.0.5"
|
||||
"bettercam>=1.0.0"
|
||||
"windows-capture>=1.5.0"
|
||||
)
|
||||
|
||||
# Download cross-platform deps (prefer binary, allow source for pure Python)
|
||||
@@ -273,6 +285,28 @@ rm -rf "$SITE_PACKAGES"/pythonwin 2>/dev/null || true
|
||||
rm -f "$SITE_PACKAGES"/PyWin32.chm 2>/dev/null || true
|
||||
find "$SITE_PACKAGES/winrt" -name "*.pyi" -delete 2>/dev/null || true
|
||||
|
||||
# Pre-compile and strip .py sources. MUST run AFTER cleanup (so we don't
|
||||
# waste work compiling files about to be deleted). Uses host python —
|
||||
# PYTHON_VERSION above must match the embedded Python major.minor or
|
||||
# the generated .pyc will ImportError on the target.
|
||||
compile_and_strip_sources "$SITE_PACKAGES" "python"
|
||||
|
||||
# Windows cross-build: host python can't load win_amd64 .pyd files, so
|
||||
# we can't `import numpy` for real. Instead, check that the submodules
|
||||
# known to be imported internally exist on disk — the same landmines that
|
||||
# cost users a broken v0.0.0.dev0 installer.
|
||||
echo " Verifying required submodules exist after cleanup..."
|
||||
for required in \
|
||||
"numpy/linalg" "numpy/lib" "numpy/matrixlib" "numpy/ma" \
|
||||
"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 — either cleanup_site_packages removed something required, or DEPS is out of sync with pyproject.toml. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo " All required submodules present."
|
||||
|
||||
WHEEL_COUNT=$(ls "$WHEEL_DIR"/*.whl 2>/dev/null | wc -l)
|
||||
echo " Installed $WHEEL_COUNT packages"
|
||||
|
||||
@@ -286,10 +320,9 @@ build_frontend
|
||||
echo "[8/9] Copying application files..."
|
||||
copy_app_files
|
||||
|
||||
# Pre-compile Python bytecode for faster startup
|
||||
echo " Pre-compiling Python bytecode..."
|
||||
# Pre-compile app source for faster startup (keep .py too — app source
|
||||
# is small and easier to debug in-place if a user reports an issue)
|
||||
python -m compileall -b -q "$APP_DIR/src" 2>/dev/null || true
|
||||
python -m compileall -b -q "$SITE_PACKAGES" 2>/dev/null || true
|
||||
|
||||
# ── Create launcher ──────────────────────────────────────────
|
||||
|
||||
@@ -301,14 +334,20 @@ cd /d "%~dp0"
|
||||
|
||||
:: Set paths
|
||||
set PYTHONPATH=%~dp0app\src
|
||||
set WLED_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
|
||||
if not exist "%~dp0data" mkdir "%~dp0data"
|
||||
if not exist "%~dp0logs" mkdir "%~dp0logs"
|
||||
|
||||
:: Start the server (tray icon handles UI and exit)
|
||||
"%~dp0python\pythonw.exe" -m wled_controller
|
||||
"%~dp0python\pythonw.exe" -m ledgrab
|
||||
LAUNCHER
|
||||
|
||||
# Convert launcher to Windows line endings
|
||||
@@ -316,7 +355,7 @@ sed -i 's/$/\r/' "$DIST_DIR/LedGrab.bat"
|
||||
|
||||
# Copy hidden launcher VBS
|
||||
mkdir -p "$DIST_DIR/scripts"
|
||||
cp server/scripts/start-hidden.vbs "$DIST_DIR/scripts/"
|
||||
cp "$SERVER_DIR/scripts/start-hidden.vbs" "$DIST_DIR/scripts/"
|
||||
|
||||
# ── Create autostart scripts ─────────────────────────────────
|
||||
|
||||
@@ -34,11 +34,11 @@ param(
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue' # faster downloads
|
||||
|
||||
$ScriptRoot = $PSScriptRoot
|
||||
$BuildDir = Join-Path $ScriptRoot "build"
|
||||
$RepoRoot = Split-Path $PSScriptRoot -Parent
|
||||
$BuildDir = $PSScriptRoot
|
||||
$DistName = "LedGrab"
|
||||
$DistDir = Join-Path $BuildDir $DistName
|
||||
$ServerDir = Join-Path $ScriptRoot "server"
|
||||
$ServerDir = Join-Path $RepoRoot "server"
|
||||
$PythonDir = Join-Path $DistDir "python"
|
||||
$AppDir = Join-Path $DistDir "app"
|
||||
|
||||
@@ -58,7 +58,7 @@ if (-not $Version) {
|
||||
}
|
||||
if (-not $Version) {
|
||||
# Parse from __init__.py
|
||||
$initFile = Join-Path $ServerDir "src\wled_controller\__init__.py"
|
||||
$initFile = Join-Path $ServerDir "src\ledgrab\__init__.py"
|
||||
$match = Select-String -Path $initFile -Pattern '__version__\s*=\s*"([^"]+)"'
|
||||
if ($match) { $Version = $match.Matches[0].Groups[1].Value }
|
||||
}
|
||||
@@ -107,7 +107,7 @@ if ($pthContent -notmatch 'Lib\\site-packages') {
|
||||
$pthContent = $pthContent.TrimEnd() + "`nLib\site-packages`n"
|
||||
}
|
||||
# Embedded Python ._pth overrides PYTHONPATH, so add the app source path
|
||||
# directly for wled_controller to be importable
|
||||
# directly for ledgrab to be importable
|
||||
if ($pthContent -notmatch '\.\.[/\\]app[/\\]src') {
|
||||
$pthContent = $pthContent.TrimEnd() + "`n..\app\src`n"
|
||||
}
|
||||
@@ -130,7 +130,7 @@ if ($LASTEXITCODE -ne 0) { throw "Failed to install pip" }
|
||||
# ── Install dependencies ──────────────────────────────────────
|
||||
|
||||
Write-Host "[5/8] Installing dependencies..."
|
||||
$extras = "camera,notifications,tray"
|
||||
$extras = "camera,notifications"
|
||||
if (-not $SkipPerf) { $extras += ",perf" }
|
||||
|
||||
# Install the project (pulls all deps via pyproject.toml), then remove
|
||||
@@ -144,10 +144,10 @@ if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host " Some optional deps may have failed (continuing)..." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Remove the installed wled_controller package to avoid duplication
|
||||
# Remove the installed ledgrab package to avoid duplication
|
||||
$sitePackages = Join-Path $PythonDir "Lib\site-packages"
|
||||
Get-ChildItem -Path $sitePackages -Filter "wled*" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Get-ChildItem -Path $sitePackages -Filter "wled*.dist-info" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Get-ChildItem -Path $sitePackages -Filter "ledgrab*" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Get-ChildItem -Path $sitePackages -Filter "ledgrab*.dist-info" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Clean up caches and test files to reduce size
|
||||
Write-Host " Cleaning up caches..."
|
||||
@@ -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..."
|
||||
@@ -206,14 +217,20 @@ cd /d "%~dp0"
|
||||
|
||||
:: Set paths
|
||||
set PYTHONPATH=%~dp0app\src
|
||||
set WLED_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
|
||||
if not exist "%~dp0data" mkdir "%~dp0data"
|
||||
if not exist "%~dp0logs" mkdir "%~dp0logs"
|
||||
|
||||
:: Start the server (tray icon handles UI and exit)
|
||||
"%~dp0python\pythonw.exe" -m wled_controller
|
||||
"%~dp0python\pythonw.exe" -m ledgrab
|
||||
'@
|
||||
|
||||
$launcherContent = $launcherContent -replace '%VERSION%', $VersionClean
|
||||
@@ -10,10 +10,11 @@
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BUILD_DIR="$SCRIPT_DIR/build"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
BUILD_DIR="$SCRIPT_DIR"
|
||||
DIST_NAME="LedGrab"
|
||||
DIST_DIR="$BUILD_DIR/$DIST_NAME"
|
||||
SERVER_DIR="$SCRIPT_DIR/server"
|
||||
SERVER_DIR="$REPO_ROOT/server"
|
||||
VENV_DIR="$DIST_DIR/venv"
|
||||
APP_DIR="$DIST_DIR/app"
|
||||
|
||||
@@ -53,6 +54,12 @@ SITE_PACKAGES=$(echo "$VENV_DIR"/lib/python*/site-packages)
|
||||
# Clean up with shared function
|
||||
cleanup_site_packages "$SITE_PACKAGES" "so" "so"
|
||||
|
||||
# Pre-compile and strip .py sources (must happen AFTER cleanup)
|
||||
compile_and_strip_sources "$SITE_PACKAGES" "python"
|
||||
|
||||
# Fail loud if cleanup broke any required import
|
||||
smoke_test_imports "$SITE_PACKAGES" "python"
|
||||
|
||||
# ── Build frontend ───────────────────────────────────────────
|
||||
|
||||
echo "[4/7] Building frontend..."
|
||||
@@ -63,6 +70,10 @@ build_frontend
|
||||
echo "[5/7] Copying application files..."
|
||||
copy_app_files
|
||||
|
||||
# Pre-compile app source for faster startup (keep .py too — app source
|
||||
# is small and easier to debug in-place if a user reports an issue)
|
||||
python -m compileall -b -q "$APP_DIR/src" 2>/dev/null || true
|
||||
|
||||
# ── Create launcher ──────────────────────────────────────────
|
||||
|
||||
echo "[6/7] Creating launcher..."
|
||||
@@ -73,12 +84,12 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
export PYTHONPATH="$SCRIPT_DIR/app/src"
|
||||
export WLED_CONFIG_PATH="$SCRIPT_DIR/app/config/default_config.yaml"
|
||||
export LEDGRAB_CONFIG_PATH="$SCRIPT_DIR/app/config/default_config.yaml"
|
||||
|
||||
mkdir -p "$SCRIPT_DIR/data" "$SCRIPT_DIR/logs"
|
||||
|
||||
source "$SCRIPT_DIR/venv/bin/activate"
|
||||
exec python -m wled_controller.main
|
||||
exec python -m ledgrab.main
|
||||
LAUNCHER
|
||||
|
||||
sed -i "s/VERSION_PLACEHOLDER/${VERSION_CLEAN}/" "$DIST_DIR/run.sh"
|
||||
@@ -22,7 +22,7 @@
|
||||
!endif
|
||||
|
||||
Name "${APPNAME} v${VERSION}"
|
||||
OutFile "build\${APPNAME}-v${VERSION}-win-x64-setup.exe"
|
||||
OutFile "${APPNAME}-v${VERSION}-win-x64-setup.exe"
|
||||
InstallDir "$LOCALAPPDATA\${APPNAME}"
|
||||
InstallDirRegKey HKCU "Software\${APPNAME}" "InstallDir"
|
||||
RequestExecutionLevel user
|
||||
@@ -30,6 +30,8 @@ SetCompressor /SOLID lzma
|
||||
|
||||
; ── Modern UI Configuration ─────────────────────────────────
|
||||
|
||||
!define MUI_ICON "..\server\src\ledgrab\static\icons\icon.ico"
|
||||
!define MUI_UNICON "..\server\src\ledgrab\static\icons\icon.ico"
|
||||
!define MUI_ABORTWARNING
|
||||
|
||||
; ── Pages ───────────────────────────────────────────────────
|
||||
@@ -85,11 +87,29 @@ Section "!${APPNAME} (required)" SecCore
|
||||
|
||||
SetOutPath "$INSTDIR"
|
||||
|
||||
; Wipe prior payload dirs before extracting. NSIS File /r MERGES files
|
||||
; on top of existing ones — on an upgrade, stale .pyc/.pyd from the old
|
||||
; version (and any files removed or renamed since) would survive,
|
||||
; producing a half-old/half-new install that presents as "version
|
||||
; mismatch" or "duplicate package" ImportErrors at runtime.
|
||||
; IMPORTANT: only touch payload dirs — never $INSTDIR\data or
|
||||
; $INSTDIR\logs (user config must be preserved across upgrades).
|
||||
RMDir /r "$INSTDIR\python"
|
||||
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 "build\LedGrab\python"
|
||||
File /r "build\LedGrab\app"
|
||||
File /r "build\LedGrab\scripts"
|
||||
File "build\LedGrab\LedGrab.bat"
|
||||
File /r "LedGrab\python"
|
||||
File /r "LedGrab\app"
|
||||
File /r "LedGrab\scripts"
|
||||
File "LedGrab\LedGrab.bat"
|
||||
|
||||
; Create data and logs directories
|
||||
CreateDirectory "$INSTDIR\data"
|
||||
@@ -102,7 +122,7 @@ Section "!${APPNAME} (required)" SecCore
|
||||
CreateDirectory "$SMPROGRAMS\${APPNAME}"
|
||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"$INSTDIR\python\pythonw.exe" 0
|
||||
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
|
||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" "$INSTDIR\uninstall.exe"
|
||||
|
||||
; Registry: install location + Add/Remove Programs entry
|
||||
@@ -117,10 +137,12 @@ Section "!${APPNAME} (required)" SecCore
|
||||
"UninstallString" '"$INSTDIR\uninstall.exe"'
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
|
||||
"InstallLocation" "$INSTDIR"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
|
||||
"DisplayIcon" "$INSTDIR\app\src\ledgrab\static\icons\icon.ico"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
|
||||
"Publisher" "Alexei Dolgolyov"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
|
||||
"URLInfoAbout" "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
||||
"URLInfoAbout" "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
|
||||
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
|
||||
"NoModify" 1
|
||||
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
|
||||
@@ -136,13 +158,16 @@ SectionEnd
|
||||
Section "Desktop shortcut" SecDesktop
|
||||
CreateShortcut "$DESKTOP\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"$INSTDIR\python\pythonw.exe" 0
|
||||
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
|
||||
SectionEnd
|
||||
|
||||
Section "Start with Windows" SecAutostart
|
||||
; Pass --autostart so the VBS sets LEDGRAB_AUTOSTART=1 and the app suppresses
|
||||
; the browser auto-open on Windows login. Manual launches (desktop / start
|
||||
; menu) don't pass the arg, so they keep opening the WebUI tab.
|
||||
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"$INSTDIR\python\pythonw.exe" 0
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}" --autostart' \
|
||||
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
|
||||
SectionEnd
|
||||
|
||||
; ── Section Descriptions ────────────────────────────────────
|
||||
@@ -171,6 +196,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,143 +0,0 @@
|
||||
# Auto-Update Plan — Phase 1: Check & Notify
|
||||
|
||||
> Created: 2026-03-25. Status: **planned, not started.**
|
||||
|
||||
## Backend Architecture
|
||||
|
||||
### Release Provider Abstraction
|
||||
|
||||
```
|
||||
core/update/
|
||||
release_provider.py — ABC: get_releases(), get_releases_page_url()
|
||||
gitea_provider.py — Gitea REST API implementation
|
||||
version_check.py — normalize_version(), is_newer() using packaging.version
|
||||
update_service.py — Background asyncio task + state machine
|
||||
```
|
||||
|
||||
**`ReleaseProvider` interface** — two methods:
|
||||
- `get_releases(limit) → list[ReleaseInfo]` — fetch releases (newest first)
|
||||
- `get_releases_page_url() → str` — link for "view on web"
|
||||
|
||||
**`GiteaReleaseProvider`** calls `GET {base_url}/api/v1/repos/{repo}/releases`. Swapping to GitHub later means implementing the same interface against `api.github.com`.
|
||||
|
||||
**Data models:**
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class AssetInfo:
|
||||
name: str # "LedGrab-v0.3.0-win-x64.zip"
|
||||
size: int # bytes
|
||||
download_url: str
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseInfo:
|
||||
tag: str # "v0.3.0"
|
||||
version: str # "0.3.0"
|
||||
name: str # "LedGrab v0.3.0"
|
||||
body: str # release notes markdown
|
||||
prerelease: bool
|
||||
published_at: str # ISO 8601
|
||||
assets: tuple[AssetInfo, ...]
|
||||
```
|
||||
|
||||
### Version Comparison
|
||||
|
||||
`version_check.py` — normalize Gitea tags to PEP 440:
|
||||
- `v0.3.0-alpha.1` → `0.3.0a1`
|
||||
- `v0.3.0-beta.2` → `0.3.0b2`
|
||||
- `v0.3.0-rc.3` → `0.3.0rc3`
|
||||
|
||||
Uses `packaging.version.Version` for comparison.
|
||||
|
||||
### Update Service
|
||||
|
||||
Follows the **AutoBackupEngine pattern**:
|
||||
- Settings in `Database.get_setting("auto_update")`
|
||||
- asyncio.Task for periodic checks
|
||||
- 30s startup delay (avoid slowing boot)
|
||||
- 60s debounce on manual checks
|
||||
|
||||
**State machine (Phase 1):** `IDLE → CHECKING → UPDATE_AVAILABLE`
|
||||
|
||||
No download/apply in Phase 1 — just detection and notification.
|
||||
|
||||
**Settings:** `enabled` (bool), `check_interval_hours` (float), `channel` ("stable" | "pre-release")
|
||||
|
||||
**Persisted state:** `dismissed_version`, `last_check` (survives restarts)
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/v1/system/update/status` | Current state + available version |
|
||||
| `POST` | `/api/v1/system/update/check` | Trigger immediate check |
|
||||
| `POST` | `/api/v1/system/update/dismiss` | Dismiss notification for current version |
|
||||
| `GET` | `/api/v1/system/update/settings` | Get settings |
|
||||
| `PUT` | `/api/v1/system/update/settings` | Update settings |
|
||||
|
||||
### Wiring
|
||||
|
||||
- New `get_update_service()` in `dependencies.py`
|
||||
- `UpdateService` created in `main.py` lifespan, `start()`/`stop()` alongside other engines
|
||||
- Router registered in `api/__init__.py`
|
||||
- WebSocket event: `update_available` fired via `processor_manager.fire_event()`
|
||||
|
||||
## Frontend
|
||||
|
||||
### Version badge highlight
|
||||
|
||||
The existing `#server-version` pill in the header gets a CSS class `has-update` when a newer version exists — changes the background to `var(--warning-color)` with a subtle pulse, making it clickable to open the update panel in settings.
|
||||
|
||||
### Notification popup
|
||||
|
||||
On `server:update_available` WebSocket event (and on page load if status says `has_update` and not dismissed):
|
||||
- A **persistent dismissible banner** slides in below the header (not the ephemeral 3s toast)
|
||||
- Shows: "Version {x.y.z} is available" + [View Release Notes] + [Dismiss]
|
||||
- Dismiss calls `POST /dismiss` and hides the bar for that version
|
||||
- Stored in `localStorage` so it doesn't re-show after page refresh for dismissed versions
|
||||
|
||||
### Settings tab: "Updates"
|
||||
|
||||
New 5th tab in the settings modal:
|
||||
- Current version display
|
||||
- "Check for updates" button + spinner
|
||||
- Channel selector (stable / pre-release) via IconSelect
|
||||
- Auto-check toggle + interval selector
|
||||
- When update available: release name, notes preview, link to releases page
|
||||
|
||||
### i18n keys
|
||||
|
||||
New `update.*` keys in `en.json`, `ru.json`, `zh.json` for all labels.
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `core/update/__init__.py` | Package init |
|
||||
| `core/update/release_provider.py` | Abstract provider interface + data models |
|
||||
| `core/update/gitea_provider.py` | Gitea API implementation |
|
||||
| `core/update/version_check.py` | Semver normalization + comparison |
|
||||
| `core/update/update_service.py` | Background service + state machine |
|
||||
| `api/routes/update.py` | REST endpoints |
|
||||
| `api/schemas/update.py` | Pydantic request/response models |
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `api/__init__.py` | Register update router |
|
||||
| `api/dependencies.py` | Add `get_update_service()` |
|
||||
| `main.py` | Create & start/stop UpdateService in lifespan |
|
||||
| `templates/modals/settings.html` | Add Updates tab |
|
||||
| `static/js/features/settings.ts` | Update check/settings UI logic |
|
||||
| `static/js/core/api.ts` | Version badge highlight on health check |
|
||||
| `static/css/layout.css` | `.has-update` styles for version badge |
|
||||
| `static/locales/en.json` | i18n keys |
|
||||
| `static/locales/ru.json` | i18n keys |
|
||||
| `static/locales/zh.json` | i18n keys |
|
||||
|
||||
## Future Phases (not in scope)
|
||||
|
||||
- **Phase 2**: Download & stage artifacts
|
||||
- **Phase 3**: Apply update & restart (external updater script, NSIS silent mode)
|
||||
- **Phase 4**: Checksums, "What's new" dialog, update history
|
||||
+10
-9
@@ -7,7 +7,8 @@
|
||||
| File | Trigger | Purpose |
|
||||
|------|---------|---------|
|
||||
| `.gitea/workflows/test.yml` | Push/PR to master | Lint (ruff) + pytest |
|
||||
| `.gitea/workflows/release.yml` | Tag `v*` | Build artifacts + create Gitea release |
|
||||
| `.gitea/workflows/release.yml` | Tag `v*` | Build Windows/Linux/Docker artifacts + create Gitea release |
|
||||
| `.gitea/workflows/build-android.yml` | Push master (android/server paths), tag `v*`, manual | Build Android APK; attach to release on tag |
|
||||
|
||||
## Release Pipeline (`release.yml`)
|
||||
|
||||
@@ -17,7 +18,7 @@ Four parallel jobs triggered by pushing a `v*` tag:
|
||||
Creates the Gitea release with a description table listing all artifacts. **The description must stay in sync with actual build outputs** — if you add/remove/rename an artifact, update the body template here.
|
||||
|
||||
### 2. `build-windows` (cross-built from Linux)
|
||||
- Runs `build-dist-windows.sh` on Ubuntu with NSIS + msitools
|
||||
- Runs `build/build-dist-windows.sh` on Ubuntu with NSIS + msitools
|
||||
- Downloads Windows embedded Python 3.11 + pip wheels cross-platform
|
||||
- Bundles tkinter from Python MSI via msiextract
|
||||
- Builds frontend (`npm run build`)
|
||||
@@ -25,7 +26,7 @@ Creates the Gitea release with a description table listing all artifacts. **The
|
||||
- Produces: **`LedGrab-{tag}-win-x64.zip`** (portable) and **`LedGrab-{tag}-setup.exe`** (NSIS installer)
|
||||
|
||||
### 3. `build-linux`
|
||||
- Runs `build-dist.sh` on Ubuntu
|
||||
- Runs `build/build-dist.sh` on Ubuntu
|
||||
- Creates a venv, installs deps, builds frontend
|
||||
- Produces: **`LedGrab-{tag}-linux-x64.tar.gz`**
|
||||
|
||||
@@ -38,8 +39,8 @@ Creates the Gitea release with a description table listing all artifacts. **The
|
||||
|
||||
| Script | Platform | Output |
|
||||
|--------|----------|--------|
|
||||
| `build-dist-windows.sh` | Linux → Windows cross-build | ZIP + NSIS installer |
|
||||
| `build-dist.sh` | Linux native | tarball |
|
||||
| `build/build-dist-windows.sh` | Linux → Windows cross-build | ZIP + NSIS installer |
|
||||
| `build/build-dist.sh` | Linux native | tarball |
|
||||
| `server/Dockerfile` | Docker | Container image |
|
||||
|
||||
## Release Versioning
|
||||
@@ -71,7 +72,7 @@ Build scripts use a fallback chain: CLI argument → exact git tag → CI env va
|
||||
- **Launch after install**: Use `MUI_FINISHPAGE_RUN_FUNCTION` (not `MUI_FINISHPAGE_RUN_PARAMETERS` — NSIS `Exec` chokes on quoting). Still requires `MUI_FINISHPAGE_RUN ""` defined for checkbox visibility
|
||||
- **Detect running instance**: `.onInit` checks file lock on `python.exe`, offers to kill process before install
|
||||
- **Uninstall preserves user data**: Remove `python/`, `app/`, `logs/` but NOT `data/`
|
||||
- **CI build**: `sudo apt-get install -y nsis msitools zip` then `makensis -DVERSION="${VERSION}" installer.nsi`
|
||||
- **CI build**: `sudo apt-get install -y nsis msitools zip` then `makensis -DVERSION="${VERSION}" build/installer.nsi`
|
||||
|
||||
## Hidden Launcher (VBS)
|
||||
|
||||
@@ -135,12 +136,12 @@ The `create-release` job has fallback logic — if the release already exists fo
|
||||
|
||||
```bash
|
||||
npm ci && npm run build # frontend
|
||||
bash build-dist-windows.sh v1.0.0 # Windows dist
|
||||
"/c/Program Files (x86)/NSIS/makensis.exe" -DVERSION="1.0.0" installer.nsi # installer
|
||||
bash build/build-dist-windows.sh v1.0.0 # Windows dist
|
||||
"/c/Program Files (x86)/NSIS/makensis.exe" -DVERSION="1.0.0" build/installer.nsi # installer
|
||||
```
|
||||
|
||||
### Iterating on installer only
|
||||
If only `installer.nsi` changed (not app code), skip the full rebuild — just re-run `makensis`. If app code changed, re-run `build-dist-windows.sh` first since `dist/` is a snapshot.
|
||||
If only `installer.nsi` changed (not app code), skip the full rebuild — just re-run `makensis`. If app code changed, re-run `build/build-dist-windows.sh` first since `dist/` is a snapshot.
|
||||
|
||||
### Common issues
|
||||
|
||||
|
||||
+33
-5
@@ -4,7 +4,7 @@
|
||||
|
||||
## CSS Custom Properties (Variables)
|
||||
|
||||
Defined in `server/src/wled_controller/static/css/base.css`.
|
||||
Defined in `server/src/ledgrab/static/css/base.css`.
|
||||
|
||||
**IMPORTANT:** There is NO `--accent` variable. Always use `--primary-color` for accent/brand color.
|
||||
|
||||
@@ -90,10 +90,26 @@ Plain `<select>` dropdowns should be enhanced with visual selectors depending on
|
||||
|
||||
Both widgets hide the native `<select>` but keep it in the DOM with its value in sync. **The `<select>` and the visual widget are two separate things — changing one does NOT automatically update the other.** After programmatically changing the `<select>` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes.
|
||||
|
||||
**CRITICAL pitfall — `<option>` elements required:** `IconSelect` does NOT create `<option>` elements in the native `<select>` — it only builds a visual popup grid. The native `<select>` must already contain matching `<option value="...">` elements (either from the Jinja2 template or added via JS) **before** `.value` is set. Setting `.value` on a `<select>` with no matching `<option>` **silently fails** — the value stays empty, and all downstream logic (section switching, auto-naming, type setup) breaks with no error. **When adding a new type to any IconSelect-enhanced `<select>`, you MUST add the `<option>` in the HTML template too.**
|
||||
|
||||
**Common pitfall:** Using a preset/palette selector (e.g. gradient preset dropdown or effect type picker) that changes the underlying `<select>` value but forgets to call `.setValue()` on the IconSelect — the visual grid still shows the old selection.
|
||||
|
||||
**IMPORTANT:** For `IconSelect` item icons, use SVG icons from `js/core/icon-paths.ts` (via `_icon(P.iconName)`) or styled `<span>` elements (e.g., `<span style="font-weight:bold">A</span>`). **Never use emoji** — they render inconsistently across platforms and themes.
|
||||
|
||||
### Dynamic entity lists (e.g. group child devices)
|
||||
|
||||
When a modal needs an **ordered list of entity references** (like child devices in a group), use the `EntityPalette.pick()` pattern instead of plain `<select>` dropdowns per row. Each row should be a styled card with:
|
||||
|
||||
- An icon (from `getDeviceTypeIcon()` / `getXxxIcon()`) showing the entity type
|
||||
- The entity name and metadata (LED count, type, etc.)
|
||||
- SVG icon buttons for reorder (chevron-up/down) and remove (trash)
|
||||
- A click handler that opens `EntityPalette.pick()` to change the selection
|
||||
- `data-*` attributes to store the selected value (not hidden `<select>` elements)
|
||||
|
||||
See `_addGroupChildRow()` in `device-discovery.ts` for the reference implementation. This pattern keeps the list visually consistent with the rest of the UI and avoids plain HTML selects.
|
||||
|
||||
For **mode/type toggles** with 2-4 fixed options (e.g. group mode: Sequence vs Independent), use `IconSelect` with descriptive icons and labels rather than radio buttons or plain selects. See `ensureGroupModeIconSelect()` for the pattern.
|
||||
|
||||
### Modal dirty check (discard unsaved changes)
|
||||
|
||||
Every editor modal **must** have a dirty check so closing with unsaved changes shows a "Discard unsaved changes?" confirmation. Use the `Modal` base class pattern from `js/core/modal.ts`:
|
||||
@@ -175,7 +191,7 @@ When adding **new tabs, sections, or major UI elements**, update the correspondi
|
||||
|
||||
When you need a new icon:
|
||||
1. Find the Lucide icon at https://lucide.dev
|
||||
2. Copy the inner SVG elements (paths, circles, rects) into `icon-paths.js` as a new export
|
||||
2. Copy the inner SVG elements (paths, circles, rects) into `icon-paths.ts` as a new export
|
||||
3. Add a corresponding `ICON_*` constant in `icons.ts` using `_svg(P.myIcon)`
|
||||
4. Import and use the constant in your feature module
|
||||
|
||||
@@ -213,7 +229,19 @@ Static HTML using `data-i18n` attributes is handled automatically by the i18n sy
|
||||
- `fetchWithAuth('/devices/dev_123', { method: 'DELETE' })` → `DELETE /api/v1/devices/dev_123`
|
||||
- Passing `/api/v1/gradients` results in **double prefix**: `/api/v1/api/v1/gradients` (404)
|
||||
|
||||
For raw `fetch()` without auth (rare), use the full path manually.
|
||||
**NEVER use raw `fetch()` or `new Audio(url)` / `new Image()` for authenticated endpoints.** These bypass the auth token and will fail with 401. Always use `fetchWithAuth()` and convert to blob URLs when needed (e.g. for `<audio>`, `<img>`, or download links):
|
||||
|
||||
```typescript
|
||||
// WRONG: no auth header — 401
|
||||
const audio = new Audio(`${API_BASE}/assets/${id}/file`);
|
||||
|
||||
// CORRECT: fetch with auth, then create blob URL
|
||||
const res = await fetchWithAuth(`/assets/${id}/file`);
|
||||
const blob = await res.blob();
|
||||
const audio = new Audio(URL.createObjectURL(blob));
|
||||
```
|
||||
|
||||
The only exception is raw `fetch()` for multipart uploads where you must set `Content-Type` to let the browser handle the boundary — but still use `getHeaders()` for the auth token.
|
||||
|
||||
## Bundling & Development Workflow
|
||||
|
||||
@@ -266,11 +294,11 @@ See [`contexts/chrome-tools.md`](chrome-tools.md) for Chrome MCP tool usage, bro
|
||||
|
||||
### Uptime / duration values
|
||||
|
||||
Use `formatUptime(seconds)` from `core/ui.js`. Outputs `{s}s`, `{m}m {s}s`, or `{h}h {m}m` via i18n keys `time.seconds`, `time.minutes_seconds`, `time.hours_minutes`.
|
||||
Use `formatUptime(seconds)` from `core/ui.ts`. Outputs `{s}s`, `{m}m {s}s`, or `{h}h {m}m` via i18n keys `time.seconds`, `time.minutes_seconds`, `time.hours_minutes`.
|
||||
|
||||
### Large numbers
|
||||
|
||||
Use `formatCompact(n)` from `core/ui.js`. Outputs `1.2K`, `3.5M` etc. Set `element.title` to the exact value for hover detail.
|
||||
Use `formatCompact(n)` from `core/ui.ts`. Outputs `1.2K`, `3.5M` etc. Set `element.title` to the exact value for hover detail.
|
||||
|
||||
### Preventing layout shift
|
||||
|
||||
|
||||
@@ -8,34 +8,38 @@ Two independent server modes with separate configs, ports, and data directories:
|
||||
|
||||
| Mode | Command | Config | Port | API Key | Data |
|
||||
| ---- | ------- | ------ | ---- | ------- | ---- |
|
||||
| **Real** | `python -m wled_controller` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` |
|
||||
| **Demo** | `python -m wled_controller.demo` | `config/demo_config.yaml` | 8081 | `demo` | `data/demo/` |
|
||||
| **Real** | `python -m ledgrab` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` |
|
||||
| **Demo** | `python -m ledgrab.demo` | `config/demo_config.yaml` | 8081 | `demo` | `data/demo/` |
|
||||
|
||||
Both can run simultaneously on different ports.
|
||||
Demo mode can also be triggered via the `LEDGRAB_DEMO` environment variable (`true`, `1`, or `yes`). This works with any launch method — Python, Docker (`-e LEDGRAB_DEMO=true`), or the installed app (`set LEDGRAB_DEMO=true` before `LedGrab.bat`).
|
||||
|
||||
Both modes can run simultaneously on different ports.
|
||||
|
||||
## Restart Procedure
|
||||
|
||||
Use the PowerShell restart script — it gracefully shuts the running server down (so stores persist to disk), kills stragglers, launches a detached replacement, and polls the port until it's actually accepting connections. Exit code is 0 on success, 1 if the new server failed to bind the port, 2 on environment errors.
|
||||
|
||||
### Real server
|
||||
|
||||
Use the PowerShell restart script — it reliably stops only the server process and starts a new detached instance:
|
||||
|
||||
```bash
|
||||
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\wled-screen-controller\server\restart.ps1"
|
||||
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\led-grab-mixed\led-grab\server\restart.ps1"
|
||||
```
|
||||
|
||||
### Demo server
|
||||
|
||||
Find and kill the process on port 8081, then restart:
|
||||
|
||||
```bash
|
||||
# Find PID
|
||||
powershell -Command "netstat -ano | Select-String ':8081.*LISTEN'"
|
||||
# Kill it
|
||||
powershell -Command "Stop-Process -Id <PID> -Force"
|
||||
# Restart
|
||||
cd server && python -m wled_controller.demo
|
||||
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\led-grab-mixed\led-grab\server\restart.ps1" `
|
||||
-Port 8081 -Module ledgrab.demo -ConfigPath "config\demo_config.yaml"
|
||||
```
|
||||
|
||||
### Useful parameters
|
||||
|
||||
- `-Port <int>` / `-Module <name>` — override the target (default: 8080 / `ledgrab`).
|
||||
- `-StartupTimeoutSec <int>` — how long to wait for the new server to bind the port (default: 30).
|
||||
- `-ShutdownTimeoutSec <int>` — how long to wait for graceful shutdown before force-killing (default: 15).
|
||||
- `-Quiet` — suppress progress output.
|
||||
- `-SkipBrowser:$false` — allow the app to open a browser tab on startup (default: skipped).
|
||||
|
||||
**Do NOT use** `Stop-Process -Name python` — it kills unrelated Python processes (VS Code extensions, etc.).
|
||||
|
||||
**Do NOT use** bash background `&` jobs — they get killed when the shell session ends.
|
||||
@@ -43,6 +47,7 @@ cd server && python -m wled_controller.demo
|
||||
## When to Restart
|
||||
|
||||
**Restart required** for changes to:
|
||||
|
||||
- API routes (`api/routes/`, `api/schemas/`)
|
||||
- Core logic (`core/*.py`)
|
||||
- Configuration (`config.py`)
|
||||
@@ -50,6 +55,7 @@ cd server && python -m wled_controller.demo
|
||||
- Data models (`storage/`)
|
||||
|
||||
**No restart needed** for:
|
||||
|
||||
- Static files (`static/js/`, `static/css/`) — but **must rebuild bundle**: `cd server && npm run build`
|
||||
- Locale files (`static/locales/*.json`) — loaded by frontend
|
||||
- Documentation files (`*.md`)
|
||||
@@ -66,13 +72,13 @@ Auto-reload is disabled (`reload=False` in `main.py`) due to watchfiles causing
|
||||
2. **New capture engines**: Verify demo mode filtering works — demo engines use `is_demo_mode()` gate in `is_available()`.
|
||||
3. **New audio engines**: Same as capture engines — `is_available()` must respect `is_demo_mode()`.
|
||||
4. **New device providers**: Gate discovery with `is_demo_mode()` like `DemoDeviceProvider.discover()`.
|
||||
5. **New seed data**: Update `server/src/wled_controller/core/demo_seed.py` to include sample entities.
|
||||
5. **New seed data**: Update `server/src/ledgrab/core/demo_seed.py` to include sample entities.
|
||||
6. **Frontend indicators**: Demo state exposed via `GET /api/v1/version` -> `demo_mode: bool`. Frontend stores it as `demoMode` in app state and sets `document.body.dataset.demo = 'true'`.
|
||||
7. **Backup/Restore**: New stores added to `STORE_MAP` in `system.py` automatically work in demo mode since the data directory is already isolated.
|
||||
|
||||
### Key files
|
||||
|
||||
- Config flag: `server/src/wled_controller/config.py` -> `Config.demo`, `is_demo_mode()`
|
||||
- Config flag: `server/src/ledgrab/config.py` -> `Config.demo`, `is_demo_mode()`
|
||||
- Demo engines: `core/capture_engines/demo_engine.py`, `core/audio/demo_engine.py`
|
||||
- Demo devices: `core/devices/demo_provider.py`
|
||||
- Seed data: `core/demo_seed.py`
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
"""The LED Screen Controller integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
CONF_SERVER_NAME,
|
||||
CONF_SERVER_URL,
|
||||
CONF_API_KEY,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
TARGET_TYPE_KEY_COLORS,
|
||||
DATA_COORDINATOR,
|
||||
DATA_WS_MANAGER,
|
||||
DATA_EVENT_LISTENER,
|
||||
)
|
||||
from .coordinator import WLEDScreenControllerCoordinator
|
||||
from .event_listener import EventStreamListener
|
||||
from .ws_manager import KeyColorsWebSocketManager
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BUTTON,
|
||||
Platform.LIGHT,
|
||||
Platform.SWITCH,
|
||||
Platform.SENSOR,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up LED Screen Controller from a config entry."""
|
||||
server_name = entry.data.get(CONF_SERVER_NAME, "LED Screen Controller")
|
||||
server_url = entry.data[CONF_SERVER_URL]
|
||||
api_key = entry.data[CONF_API_KEY]
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
coordinator = WLEDScreenControllerCoordinator(
|
||||
hass,
|
||||
session,
|
||||
server_url,
|
||||
api_key,
|
||||
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
|
||||
)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
ws_manager = KeyColorsWebSocketManager(hass, server_url, api_key)
|
||||
|
||||
event_listener = EventStreamListener(hass, server_url, api_key, coordinator)
|
||||
await event_listener.start()
|
||||
|
||||
# Create device entries for each target and remove stale ones
|
||||
device_registry = dr.async_get(hass)
|
||||
current_identifiers: set[tuple[str, str]] = set()
|
||||
if coordinator.data and "targets" in coordinator.data:
|
||||
for target_id, target_data in coordinator.data["targets"].items():
|
||||
info = target_data["info"]
|
||||
target_type = info.get("target_type", "led")
|
||||
model = (
|
||||
"Key Colors Target"
|
||||
if target_type == TARGET_TYPE_KEY_COLORS
|
||||
else "LED Target"
|
||||
)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, target_id)},
|
||||
name=info.get("name", target_id),
|
||||
manufacturer=server_name,
|
||||
model=model,
|
||||
configuration_url=server_url,
|
||||
)
|
||||
current_identifiers.add((DOMAIN, target_id))
|
||||
|
||||
# Create a single "Scenes" device for scene preset buttons
|
||||
scenes_identifier = (DOMAIN, f"{entry.entry_id}_scenes")
|
||||
scene_presets = coordinator.data.get("scene_presets", []) if coordinator.data else []
|
||||
if scene_presets:
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={scenes_identifier},
|
||||
name="Scenes",
|
||||
manufacturer=server_name,
|
||||
model="Scene Presets",
|
||||
configuration_url=server_url,
|
||||
)
|
||||
current_identifiers.add(scenes_identifier)
|
||||
|
||||
# Remove devices for targets that no longer exist
|
||||
for device_entry in dr.async_entries_for_config_entry(
|
||||
device_registry, entry.entry_id
|
||||
):
|
||||
if not device_entry.identifiers & current_identifiers:
|
||||
_LOGGER.info("Removing stale device: %s", device_entry.name)
|
||||
device_registry.async_remove_device(device_entry.id)
|
||||
|
||||
# Store data
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
DATA_COORDINATOR: coordinator,
|
||||
DATA_WS_MANAGER: ws_manager,
|
||||
DATA_EVENT_LISTENER: event_listener,
|
||||
}
|
||||
|
||||
# Track target and scene IDs to detect changes
|
||||
known_target_ids = set(
|
||||
coordinator.data.get("targets", {}).keys() if coordinator.data else []
|
||||
)
|
||||
known_scene_ids = set(
|
||||
p["id"] for p in (coordinator.data.get("scene_presets", []) if coordinator.data else [])
|
||||
)
|
||||
|
||||
def _on_coordinator_update() -> None:
|
||||
"""Manage WS connections and detect target list changes."""
|
||||
nonlocal known_target_ids, known_scene_ids
|
||||
|
||||
if not coordinator.data:
|
||||
return
|
||||
|
||||
targets = coordinator.data.get("targets", {})
|
||||
|
||||
# Start/stop WS connections for KC targets based on processing state
|
||||
for target_id, target_data in targets.items():
|
||||
info = target_data.get("info", {})
|
||||
state = target_data.get("state") or {}
|
||||
if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
|
||||
if state.get("processing"):
|
||||
if target_id not in ws_manager._connections:
|
||||
hass.async_create_task(ws_manager.start_listening(target_id))
|
||||
else:
|
||||
if target_id in ws_manager._connections:
|
||||
hass.async_create_task(ws_manager.stop_listening(target_id))
|
||||
|
||||
# Reload if target or scene list changed
|
||||
current_ids = set(targets.keys())
|
||||
current_scene_ids = set(
|
||||
p["id"] for p in coordinator.data.get("scene_presets", [])
|
||||
)
|
||||
if current_ids != known_target_ids or current_scene_ids != known_scene_ids:
|
||||
known_target_ids = current_ids
|
||||
known_scene_ids = current_scene_ids
|
||||
_LOGGER.info("Target or scene list changed, reloading integration")
|
||||
hass.async_create_task(
|
||||
hass.config_entries.async_reload(entry.entry_id)
|
||||
)
|
||||
|
||||
coordinator.async_add_listener(_on_coordinator_update)
|
||||
|
||||
# Register set_leds service (once across all entries)
|
||||
async def handle_set_leds(call) -> None:
|
||||
"""Handle the set_leds service call."""
|
||||
source_id = call.data["source_id"]
|
||||
segments = call.data["segments"]
|
||||
# Route to the coordinator that owns this source
|
||||
for entry_data in hass.data[DOMAIN].values():
|
||||
coord = entry_data.get(DATA_COORDINATOR)
|
||||
if not coord or not coord.data:
|
||||
continue
|
||||
source_ids = {
|
||||
s["id"] for s in coord.data.get("css_sources", [])
|
||||
}
|
||||
if source_id in source_ids:
|
||||
await coord.push_segments(source_id, segments)
|
||||
return
|
||||
_LOGGER.error("No server found with source_id %s", source_id)
|
||||
|
||||
if not hass.services.has_service(DOMAIN, "set_leds"):
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"set_leds",
|
||||
handle_set_leds,
|
||||
schema=vol.Schema({
|
||||
vol.Required("source_id"): str,
|
||||
vol.Required("segments"): list,
|
||||
}),
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
entry_data = hass.data[DOMAIN][entry.entry_id]
|
||||
await entry_data[DATA_WS_MANAGER].shutdown()
|
||||
await entry_data[DATA_EVENT_LISTENER].shutdown()
|
||||
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
# Unregister service if no entries remain
|
||||
if not hass.data[DOMAIN]:
|
||||
hass.services.async_remove(DOMAIN, "set_leds")
|
||||
|
||||
return unload_ok
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Button platform for LED Screen Controller — scene preset activation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, DATA_COORDINATOR
|
||||
from .coordinator import WLEDScreenControllerCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up scene preset buttons."""
|
||||
data = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
|
||||
|
||||
entities = []
|
||||
if coordinator.data:
|
||||
for preset in coordinator.data.get("scene_presets", []):
|
||||
entities.append(
|
||||
SceneActivateButton(coordinator, preset, entry.entry_id)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class SceneActivateButton(CoordinatorEntity, ButtonEntity):
|
||||
"""Button that activates a scene preset."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
preset: dict[str, Any],
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the button."""
|
||||
super().__init__(coordinator)
|
||||
self._preset_id = preset["id"]
|
||||
self._entry_id = entry_id
|
||||
self._attr_unique_id = f"{entry_id}_scene_{preset['id']}"
|
||||
self._attr_translation_key = "activate_scene"
|
||||
self._attr_translation_placeholders = {"scene_name": preset["name"]}
|
||||
self._attr_icon = "mdi:palette"
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return device information — all scene buttons belong to the Scenes device."""
|
||||
return {"identifiers": {(DOMAIN, f"{self._entry_id}_scenes")}}
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
if not self.coordinator.data:
|
||||
return False
|
||||
return self._preset_id in {
|
||||
p["id"] for p in self.coordinator.data.get("scene_presets", [])
|
||||
}
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Activate the scene preset."""
|
||||
await self.coordinator.activate_scene(self._preset_id)
|
||||
@@ -1,127 +0,0 @@
|
||||
"""Config flow for LED Screen Controller integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import aiohttp
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN, CONF_SERVER_NAME, CONF_SERVER_URL, CONF_API_KEY, DEFAULT_TIMEOUT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_SERVER_NAME, default="LED Screen Controller"): str,
|
||||
vol.Required(CONF_SERVER_URL, default="http://localhost:8080"): str,
|
||||
vol.Optional(CONF_API_KEY, default=""): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def normalize_url(url: str) -> str:
|
||||
"""Normalize URL to ensure port is an integer."""
|
||||
parsed = urlparse(url)
|
||||
|
||||
if parsed.port is not None:
|
||||
netloc = parsed.hostname or "localhost"
|
||||
port = int(parsed.port)
|
||||
if port != (443 if parsed.scheme == "https" else 80):
|
||||
netloc = f"{netloc}:{port}"
|
||||
parsed = parsed._replace(netloc=netloc)
|
||||
|
||||
return urlunparse(parsed)
|
||||
|
||||
|
||||
async def validate_server(
|
||||
hass: HomeAssistant, server_url: str, api_key: str
|
||||
) -> dict[str, Any]:
|
||||
"""Validate server connectivity and API key."""
|
||||
session = async_get_clientsession(hass)
|
||||
timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)
|
||||
|
||||
# Step 1: Check connectivity via health endpoint (no auth needed)
|
||||
try:
|
||||
async with session.get(f"{server_url}/health", timeout=timeout) as resp:
|
||||
if resp.status != 200:
|
||||
raise ConnectionError(f"Server returned status {resp.status}")
|
||||
data = await resp.json()
|
||||
version = data.get("version", "unknown")
|
||||
except aiohttp.ClientError as err:
|
||||
raise ConnectionError(f"Cannot connect to server: {err}") from err
|
||||
|
||||
# Step 2: Validate API key via authenticated endpoint (skip if no key and auth not required)
|
||||
auth_required = data.get("auth_required", True)
|
||||
if api_key:
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
try:
|
||||
async with session.get(
|
||||
f"{server_url}/api/v1/output-targets",
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
) as resp:
|
||||
if resp.status == 401:
|
||||
raise PermissionError("Invalid API key")
|
||||
resp.raise_for_status()
|
||||
except PermissionError:
|
||||
raise
|
||||
except aiohttp.ClientError as err:
|
||||
raise ConnectionError(f"API request failed: {err}") from err
|
||||
elif auth_required:
|
||||
raise PermissionError("Server requires an API key")
|
||||
|
||||
return {"version": version}
|
||||
|
||||
|
||||
class WLEDScreenControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for LED Screen Controller."""
|
||||
|
||||
VERSION = 2
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
server_name = user_input.get(CONF_SERVER_NAME, "LED Screen Controller")
|
||||
server_url = normalize_url(user_input[CONF_SERVER_URL].rstrip("/"))
|
||||
api_key = user_input[CONF_API_KEY]
|
||||
|
||||
try:
|
||||
await validate_server(self.hass, server_url, api_key)
|
||||
|
||||
await self.async_set_unique_id(server_url)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=server_name,
|
||||
data={
|
||||
CONF_SERVER_NAME: server_name,
|
||||
CONF_SERVER_URL: server_url,
|
||||
CONF_API_KEY: api_key,
|
||||
},
|
||||
)
|
||||
|
||||
except ConnectionError as err:
|
||||
_LOGGER.error("Connection error: %s", err)
|
||||
errors["base"] = "cannot_connect"
|
||||
except PermissionError:
|
||||
errors["base"] = "invalid_api_key"
|
||||
except Exception as err:
|
||||
_LOGGER.exception("Unexpected exception: %s", err)
|
||||
errors["base"] = "unknown"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
@@ -1,23 +0,0 @@
|
||||
"""Constants for the LED Screen Controller integration."""
|
||||
|
||||
DOMAIN = "wled_screen_controller"
|
||||
|
||||
# Configuration
|
||||
CONF_SERVER_NAME = "server_name"
|
||||
CONF_SERVER_URL = "server_url"
|
||||
CONF_API_KEY = "api_key"
|
||||
|
||||
# Default values
|
||||
DEFAULT_SCAN_INTERVAL = 3 # seconds
|
||||
DEFAULT_TIMEOUT = 10 # seconds
|
||||
WS_RECONNECT_DELAY = 5 # seconds
|
||||
WS_MAX_RECONNECT_DELAY = 60 # seconds
|
||||
|
||||
# Target types
|
||||
TARGET_TYPE_LED = "led"
|
||||
TARGET_TYPE_KEY_COLORS = "key_colors"
|
||||
|
||||
# Data keys stored in hass.data[DOMAIN][entry_id]
|
||||
DATA_COORDINATOR = "coordinator"
|
||||
DATA_WS_MANAGER = "ws_manager"
|
||||
DATA_EVENT_LISTENER = "event_listener"
|
||||
@@ -1,459 +0,0 @@
|
||||
"""Data update coordinator for LED Screen Controller."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
DEFAULT_TIMEOUT,
|
||||
TARGET_TYPE_KEY_COLORS,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
"""Class to manage fetching LED Screen Controller data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
session: aiohttp.ClientSession,
|
||||
server_url: str,
|
||||
api_key: str,
|
||||
update_interval: timedelta,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self.server_url = server_url
|
||||
self.session = session
|
||||
self.api_key = api_key
|
||||
self.server_version = "unknown"
|
||||
self._auth_headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
self._timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=update_interval,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch data from API."""
|
||||
try:
|
||||
async with asyncio.timeout(DEFAULT_TIMEOUT * 3):
|
||||
if self.server_version == "unknown":
|
||||
await self._fetch_server_version()
|
||||
|
||||
targets_list = await self._fetch_targets()
|
||||
|
||||
# Fetch state and metrics for all targets in parallel
|
||||
targets_data: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def fetch_target_data(target: dict) -> tuple[str, dict]:
|
||||
target_id = target["id"]
|
||||
try:
|
||||
state, metrics = await asyncio.gather(
|
||||
self._fetch_target_state(target_id),
|
||||
self._fetch_target_metrics(target_id),
|
||||
)
|
||||
except Exception as err:
|
||||
_LOGGER.warning(
|
||||
"Failed to fetch data for target %s: %s",
|
||||
target_id,
|
||||
err,
|
||||
)
|
||||
state = None
|
||||
metrics = None
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"info": target,
|
||||
"state": state,
|
||||
"metrics": metrics,
|
||||
}
|
||||
|
||||
# Fetch rectangles for key_colors targets
|
||||
if target.get("target_type") == TARGET_TYPE_KEY_COLORS:
|
||||
kc_settings = target.get("key_colors_settings") or {}
|
||||
template_id = kc_settings.get("pattern_template_id", "")
|
||||
if template_id:
|
||||
result["rectangles"] = await self._fetch_rectangles(
|
||||
template_id
|
||||
)
|
||||
else:
|
||||
result["rectangles"] = []
|
||||
else:
|
||||
result["rectangles"] = []
|
||||
|
||||
return target_id, result
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(fetch_target_data(t) for t in targets_list),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
for r in results:
|
||||
if isinstance(r, Exception):
|
||||
_LOGGER.warning("Target fetch failed: %s", r)
|
||||
continue
|
||||
target_id, data = r
|
||||
targets_data[target_id] = data
|
||||
|
||||
# Fetch devices, CSS sources, value sources, and scene presets in parallel
|
||||
devices_data, css_sources, value_sources, scene_presets = (
|
||||
await asyncio.gather(
|
||||
self._fetch_devices(),
|
||||
self._fetch_css_sources(),
|
||||
self._fetch_value_sources(),
|
||||
self._fetch_scene_presets(),
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"targets": targets_data,
|
||||
"devices": devices_data,
|
||||
"css_sources": css_sources,
|
||||
"value_sources": value_sources,
|
||||
"scene_presets": scene_presets,
|
||||
"server_version": self.server_version,
|
||||
}
|
||||
|
||||
except asyncio.TimeoutError as err:
|
||||
raise UpdateFailed(f"Timeout fetching data: {err}") from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
|
||||
async def _fetch_server_version(self) -> None:
|
||||
"""Fetch server version from health endpoint."""
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/health",
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
self.server_version = data.get("version", "unknown")
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Failed to fetch server version: %s", err)
|
||||
self.server_version = "unknown"
|
||||
|
||||
async def _fetch_targets(self) -> list[dict[str, Any]]:
|
||||
"""Fetch all output targets."""
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/output-targets",
|
||||
headers=self._auth_headers,
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
return data.get("targets", [])
|
||||
|
||||
async def _fetch_target_state(self, target_id: str) -> dict[str, Any]:
|
||||
"""Fetch target processing state."""
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/output-targets/{target_id}/state",
|
||||
headers=self._auth_headers,
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
|
||||
async def _fetch_target_metrics(self, target_id: str) -> dict[str, Any]:
|
||||
"""Fetch target metrics."""
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/output-targets/{target_id}/metrics",
|
||||
headers=self._auth_headers,
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
|
||||
async def _fetch_rectangles(self, template_id: str) -> list[dict]:
|
||||
"""Fetch rectangles for a pattern template (no cache — always fresh)."""
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/pattern-templates/{template_id}",
|
||||
headers=self._auth_headers,
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
return data.get("rectangles", [])
|
||||
except Exception as err:
|
||||
_LOGGER.warning(
|
||||
"Failed to fetch pattern template %s: %s", template_id, err
|
||||
)
|
||||
return []
|
||||
|
||||
async def _fetch_devices(self) -> dict[str, dict[str, Any]]:
|
||||
"""Fetch all devices with capabilities and brightness."""
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/devices",
|
||||
headers=self._auth_headers,
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
devices = data.get("devices", [])
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Failed to fetch devices: %s", err)
|
||||
return {}
|
||||
|
||||
# Fetch brightness for all capable devices in parallel
|
||||
async def fetch_device_entry(device: dict) -> tuple[str, dict[str, Any]]:
|
||||
device_id = device["id"]
|
||||
entry: dict[str, Any] = {"info": device, "brightness": None}
|
||||
if "brightness_control" in (device.get("capabilities") or []):
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
|
||||
headers=self._auth_headers,
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
bri_data = await resp.json()
|
||||
entry["brightness"] = bri_data.get("brightness")
|
||||
except Exception as err:
|
||||
_LOGGER.warning(
|
||||
"Failed to fetch brightness for device %s: %s",
|
||||
device_id, err,
|
||||
)
|
||||
return device_id, entry
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(fetch_device_entry(d) for d in devices),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
devices_data: dict[str, dict[str, Any]] = {}
|
||||
for r in results:
|
||||
if isinstance(r, Exception):
|
||||
_LOGGER.warning("Device fetch failed: %s", r)
|
||||
continue
|
||||
device_id, entry = r
|
||||
devices_data[device_id] = entry
|
||||
|
||||
return devices_data
|
||||
|
||||
async def set_brightness(self, device_id: str, brightness: int) -> None:
|
||||
"""Set brightness for a device."""
|
||||
async with self.session.put(
|
||||
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
|
||||
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||
json={"brightness": brightness},
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
_LOGGER.error(
|
||||
"Failed to set brightness for device %s: %s %s",
|
||||
device_id, resp.status, body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
await self.async_request_refresh()
|
||||
|
||||
async def set_color(self, device_id: str, color: list[int] | None) -> None:
|
||||
"""Set or clear the static color for a device."""
|
||||
async with self.session.put(
|
||||
f"{self.server_url}/api/v1/devices/{device_id}/color",
|
||||
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||
json={"color": color},
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
_LOGGER.error(
|
||||
"Failed to set color for device %s: %s %s",
|
||||
device_id, resp.status, body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
await self.async_request_refresh()
|
||||
|
||||
async def set_kc_brightness(self, target_id: str, brightness: int) -> None:
|
||||
"""Set brightness for a Key Colors target (0-255 mapped to 0.0-1.0)."""
|
||||
brightness_float = round(brightness / 255, 4)
|
||||
async with self.session.put(
|
||||
f"{self.server_url}/api/v1/output-targets/{target_id}",
|
||||
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||
json={"key_colors_settings": {"brightness": brightness_float}},
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
_LOGGER.error(
|
||||
"Failed to set KC brightness for target %s: %s %s",
|
||||
target_id, resp.status, body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
await self.async_request_refresh()
|
||||
|
||||
async def _fetch_css_sources(self) -> list[dict[str, Any]]:
|
||||
"""Fetch all color strip sources."""
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/color-strip-sources",
|
||||
headers=self._auth_headers,
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
return data.get("sources", [])
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Failed to fetch CSS sources: %s", err)
|
||||
return []
|
||||
|
||||
async def _fetch_value_sources(self) -> list[dict[str, Any]]:
|
||||
"""Fetch all value sources."""
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/value-sources",
|
||||
headers=self._auth_headers,
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
return data.get("sources", [])
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Failed to fetch value sources: %s", err)
|
||||
return []
|
||||
|
||||
async def _fetch_scene_presets(self) -> list[dict[str, Any]]:
|
||||
"""Fetch all scene presets."""
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/scene-presets",
|
||||
headers=self._auth_headers,
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
return data.get("presets", [])
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Failed to fetch scene presets: %s", err)
|
||||
return []
|
||||
|
||||
async def push_colors(self, source_id: str, colors: list[list[int]]) -> None:
|
||||
"""Push flat color array to an api_input CSS source."""
|
||||
async with self.session.post(
|
||||
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
|
||||
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||
json={"colors": colors},
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status not in (200, 204):
|
||||
body = await resp.text()
|
||||
_LOGGER.error(
|
||||
"Failed to push colors to source %s: %s %s",
|
||||
source_id, resp.status, body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def push_segments(self, source_id: str, segments: list[dict]) -> None:
|
||||
"""Push segment data to an api_input CSS source."""
|
||||
async with self.session.post(
|
||||
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
|
||||
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||
json={"segments": segments},
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status not in (200, 204):
|
||||
body = await resp.text()
|
||||
_LOGGER.error(
|
||||
"Failed to push segments to source %s: %s %s",
|
||||
source_id, resp.status, body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def activate_scene(self, preset_id: str) -> None:
|
||||
"""Activate a scene preset."""
|
||||
async with self.session.post(
|
||||
f"{self.server_url}/api/v1/scene-presets/{preset_id}/activate",
|
||||
headers=self._auth_headers,
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
_LOGGER.error(
|
||||
"Failed to activate scene %s: %s %s",
|
||||
preset_id, resp.status, body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
await self.async_request_refresh()
|
||||
|
||||
async def update_source(self, source_id: str, **kwargs: Any) -> None:
|
||||
"""Update a color strip source's fields."""
|
||||
async with self.session.put(
|
||||
f"{self.server_url}/api/v1/color-strip-sources/{source_id}",
|
||||
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||
json=kwargs,
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
_LOGGER.error(
|
||||
"Failed to update source %s: %s %s",
|
||||
source_id, resp.status, body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def update_target(self, target_id: str, **kwargs: Any) -> None:
|
||||
"""Update an output target's fields."""
|
||||
async with self.session.put(
|
||||
f"{self.server_url}/api/v1/output-targets/{target_id}",
|
||||
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||
json=kwargs,
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
_LOGGER.error(
|
||||
"Failed to update target %s: %s %s",
|
||||
target_id, resp.status, body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
await self.async_request_refresh()
|
||||
|
||||
async def start_processing(self, target_id: str) -> None:
|
||||
"""Start processing for a target."""
|
||||
async with self.session.post(
|
||||
f"{self.server_url}/api/v1/output-targets/{target_id}/start",
|
||||
headers=self._auth_headers,
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status == 409:
|
||||
_LOGGER.debug("Target %s already processing", target_id)
|
||||
elif resp.status != 200:
|
||||
body = await resp.text()
|
||||
_LOGGER.error(
|
||||
"Failed to start target %s: %s %s",
|
||||
target_id, resp.status, body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
await self.async_request_refresh()
|
||||
|
||||
async def stop_processing(self, target_id: str) -> None:
|
||||
"""Stop processing for a target."""
|
||||
async with self.session.post(
|
||||
f"{self.server_url}/api/v1/output-targets/{target_id}/stop",
|
||||
headers=self._auth_headers,
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status == 409:
|
||||
_LOGGER.debug("Target %s already stopped", target_id)
|
||||
elif resp.status != 200:
|
||||
body = await resp.text()
|
||||
_LOGGER.error(
|
||||
"Failed to stop target %s: %s %s",
|
||||
target_id, resp.status, body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
await self.async_request_refresh()
|
||||
@@ -1,95 +0,0 @@
|
||||
"""WebSocket event listener for server state change notifications."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import WS_RECONNECT_DELAY, WS_MAX_RECONNECT_DELAY
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventStreamListener:
|
||||
"""Listens to server WS endpoint for state change events.
|
||||
|
||||
Triggers a coordinator refresh whenever a target starts or stops processing,
|
||||
so HAOS entities react near-instantly to external state changes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
server_url: str,
|
||||
api_key: str,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
) -> None:
|
||||
self._hass = hass
|
||||
self._server_url = server_url
|
||||
self._api_key = api_key
|
||||
self._coordinator = coordinator
|
||||
self._task: asyncio.Task | None = None
|
||||
self._shutting_down = False
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start listening to the event stream."""
|
||||
self._task = self._hass.async_create_background_task(
|
||||
self._ws_loop(),
|
||||
"wled_screen_controller_events",
|
||||
)
|
||||
|
||||
async def _ws_loop(self) -> None:
|
||||
"""WebSocket connection loop with reconnection."""
|
||||
delay = WS_RECONNECT_DELAY
|
||||
session = async_get_clientsession(self._hass)
|
||||
ws_base = self._server_url.replace("http://", "ws://").replace(
|
||||
"https://", "wss://"
|
||||
)
|
||||
url = f"{ws_base}/api/v1/events/ws?token={self._api_key}"
|
||||
|
||||
while not self._shutting_down:
|
||||
try:
|
||||
async with session.ws_connect(url) as ws:
|
||||
delay = WS_RECONNECT_DELAY # reset on successful connect
|
||||
_LOGGER.debug("Event stream connected")
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
data = json.loads(msg.data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if data.get("type") == "state_change":
|
||||
await self._coordinator.async_request_refresh()
|
||||
elif msg.type in (
|
||||
aiohttp.WSMsgType.CLOSED,
|
||||
aiohttp.WSMsgType.ERROR,
|
||||
):
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as err:
|
||||
_LOGGER.debug("Event stream connection error: %s", err)
|
||||
except Exception as err:
|
||||
_LOGGER.error("Unexpected event stream error: %s", err)
|
||||
|
||||
if self._shutting_down:
|
||||
break
|
||||
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, WS_MAX_RECONNECT_DELAY)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Stop listening."""
|
||||
self._shutting_down = True
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._task
|
||||
self._task = None
|
||||
@@ -1,151 +0,0 @@
|
||||
"""Light platform for LED Screen Controller (api_input CSS sources)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_RGB_COLOR,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, DATA_COORDINATOR
|
||||
from .coordinator import WLEDScreenControllerCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up LED Screen Controller api_input lights."""
|
||||
data = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
|
||||
|
||||
entities = []
|
||||
if coordinator.data:
|
||||
for source in coordinator.data.get("css_sources", []):
|
||||
if source.get("source_type") == "api_input":
|
||||
entities.append(
|
||||
ApiInputLight(coordinator, source, entry.entry_id)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ApiInputLight(CoordinatorEntity, LightEntity):
|
||||
"""Representation of an api_input CSS source as a light entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_color_mode = ColorMode.RGB
|
||||
_attr_supported_color_modes = {ColorMode.RGB}
|
||||
_attr_translation_key = "api_input_light"
|
||||
_attr_icon = "mdi:led-strip-variant"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
source: dict[str, Any],
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the light."""
|
||||
super().__init__(coordinator)
|
||||
self._source_id: str = source["id"]
|
||||
self._source_name: str = source.get("name", self._source_id)
|
||||
self._entry_id = entry_id
|
||||
self._attr_unique_id = f"{self._source_id}_light"
|
||||
|
||||
# Restore state from fallback_color
|
||||
fallback = self._get_fallback_color()
|
||||
is_off = fallback == [0, 0, 0]
|
||||
self._is_on: bool = not is_off
|
||||
self._rgb_color: tuple[int, int, int] = (
|
||||
(255, 255, 255) if is_off else tuple(fallback) # type: ignore[arg-type]
|
||||
)
|
||||
self._brightness: int = 255
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return device information — one virtual device per api_input source."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self._source_id)},
|
||||
"name": self._source_name,
|
||||
"manufacturer": "WLED Screen Controller",
|
||||
"model": "API Input CSS Source",
|
||||
}
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the entity name."""
|
||||
return self._source_name
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the light is on."""
|
||||
return self._is_on
|
||||
|
||||
@property
|
||||
def rgb_color(self) -> tuple[int, int, int]:
|
||||
"""Return the current RGB color."""
|
||||
return self._rgb_color
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
"""Return the current brightness (0-255)."""
|
||||
return self._brightness
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the light, optionally setting color and brightness."""
|
||||
if ATTR_RGB_COLOR in kwargs:
|
||||
self._rgb_color = kwargs[ATTR_RGB_COLOR]
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
self._brightness = kwargs[ATTR_BRIGHTNESS]
|
||||
|
||||
# Scale RGB by brightness
|
||||
scale = self._brightness / 255
|
||||
r, g, b = self._rgb_color
|
||||
scaled = [round(r * scale), round(g * scale), round(b * scale)]
|
||||
|
||||
await self.coordinator.push_segments(
|
||||
self._source_id,
|
||||
[{"start": 0, "length": 9999, "mode": "solid", "color": scaled}],
|
||||
)
|
||||
# Update fallback_color so the color persists beyond the timeout
|
||||
await self.coordinator.update_source(
|
||||
self._source_id, fallback_color=scaled,
|
||||
)
|
||||
self._is_on = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the light by pushing black and setting fallback to black."""
|
||||
off_color = [0, 0, 0]
|
||||
await self.coordinator.push_segments(
|
||||
self._source_id,
|
||||
[{"start": 0, "length": 9999, "mode": "solid", "color": off_color}],
|
||||
)
|
||||
await self.coordinator.update_source(
|
||||
self._source_id, fallback_color=off_color,
|
||||
)
|
||||
self._is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _get_fallback_color(self) -> list[int]:
|
||||
"""Read fallback_color from the source config in coordinator data."""
|
||||
if not self.coordinator.data:
|
||||
return [0, 0, 0]
|
||||
for source in self.coordinator.data.get("css_sources", []):
|
||||
if source.get("id") == self._source_id:
|
||||
fallback = source.get("fallback_color")
|
||||
if fallback and len(fallback) >= 3:
|
||||
return list(fallback[:3])
|
||||
break
|
||||
return [0, 0, 0]
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "wled_screen_controller",
|
||||
"name": "LED Screen Controller",
|
||||
"codeowners": ["@alexeidolgolyov"],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed",
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues",
|
||||
"requirements": ["aiohttp>=3.9.0"],
|
||||
"version": "0.2.0"
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
"""Number platform for LED Screen Controller (device & KC target brightness)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.number import NumberEntity, NumberMode
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, DATA_COORDINATOR, TARGET_TYPE_KEY_COLORS
|
||||
from .coordinator import WLEDScreenControllerCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up LED Screen Controller brightness numbers."""
|
||||
data = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
|
||||
|
||||
entities = []
|
||||
if coordinator.data and "targets" in coordinator.data:
|
||||
devices = coordinator.data.get("devices") or {}
|
||||
|
||||
for target_id, target_data in coordinator.data["targets"].items():
|
||||
info = target_data["info"]
|
||||
|
||||
if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
|
||||
# KC target — brightness lives in key_colors_settings
|
||||
entities.append(
|
||||
WLEDScreenControllerKCBrightness(
|
||||
coordinator, target_id, entry.entry_id,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# LED target — brightness lives on the device
|
||||
device_id = info.get("device_id", "")
|
||||
if not device_id:
|
||||
continue
|
||||
|
||||
device_data = devices.get(device_id)
|
||||
if not device_data:
|
||||
continue
|
||||
|
||||
capabilities = device_data.get("info", {}).get("capabilities") or []
|
||||
if "brightness_control" not in capabilities or "static_color" in capabilities:
|
||||
continue
|
||||
|
||||
entities.append(
|
||||
WLEDScreenControllerBrightness(
|
||||
coordinator, target_id, device_id, entry.entry_id,
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class WLEDScreenControllerBrightness(CoordinatorEntity, NumberEntity):
|
||||
"""Brightness control for an LED device associated with a target."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_native_min_value = 0
|
||||
_attr_native_max_value = 255
|
||||
_attr_native_step = 1
|
||||
_attr_mode = NumberMode.SLIDER
|
||||
_attr_icon = "mdi:brightness-6"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
target_id: str,
|
||||
device_id: str,
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the brightness number."""
|
||||
super().__init__(coordinator)
|
||||
self._target_id = target_id
|
||||
self._device_id = device_id
|
||||
self._entry_id = entry_id
|
||||
self._attr_unique_id = f"{target_id}_brightness"
|
||||
self._attr_translation_key = "brightness"
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return device information."""
|
||||
return {"identifiers": {(DOMAIN, self._target_id)}}
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the current brightness value."""
|
||||
if not self.coordinator.data:
|
||||
return None
|
||||
device_data = self.coordinator.data.get("devices", {}).get(self._device_id)
|
||||
if not device_data:
|
||||
return None
|
||||
return device_data.get("brightness")
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
if not self.coordinator.data:
|
||||
return False
|
||||
targets = self.coordinator.data.get("targets", {})
|
||||
devices = self.coordinator.data.get("devices", {})
|
||||
return self._target_id in targets and self._device_id in devices
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set brightness value."""
|
||||
await self.coordinator.set_brightness(self._device_id, int(value))
|
||||
|
||||
|
||||
class WLEDScreenControllerKCBrightness(CoordinatorEntity, NumberEntity):
|
||||
"""Brightness control for a Key Colors target."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_native_min_value = 0
|
||||
_attr_native_max_value = 255
|
||||
_attr_native_step = 1
|
||||
_attr_mode = NumberMode.SLIDER
|
||||
_attr_icon = "mdi:brightness-6"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
target_id: str,
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the KC brightness number."""
|
||||
super().__init__(coordinator)
|
||||
self._target_id = target_id
|
||||
self._entry_id = entry_id
|
||||
self._attr_unique_id = f"{target_id}_brightness"
|
||||
self._attr_translation_key = "brightness"
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return device information."""
|
||||
return {"identifiers": {(DOMAIN, self._target_id)}}
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the current brightness value (0-255)."""
|
||||
if not self.coordinator.data:
|
||||
return None
|
||||
target_data = self.coordinator.data.get("targets", {}).get(self._target_id)
|
||||
if not target_data:
|
||||
return None
|
||||
kc_settings = target_data.get("info", {}).get("key_colors_settings") or {}
|
||||
brightness_float = kc_settings.get("brightness", 1.0)
|
||||
return round(brightness_float * 255)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
if not self.coordinator.data:
|
||||
return False
|
||||
return self._target_id in self.coordinator.data.get("targets", {})
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set brightness value."""
|
||||
await self.coordinator.set_kc_brightness(self._target_id, int(value))
|
||||
@@ -1,177 +0,0 @@
|
||||
"""Select platform for LED Screen Controller (CSS source & brightness source)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, DATA_COORDINATOR, TARGET_TYPE_KEY_COLORS
|
||||
from .coordinator import WLEDScreenControllerCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
NONE_OPTION = "— None —"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up LED Screen Controller select entities."""
|
||||
data = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
|
||||
|
||||
entities: list[SelectEntity] = []
|
||||
if coordinator.data and "targets" in coordinator.data:
|
||||
for target_id, target_data in coordinator.data["targets"].items():
|
||||
info = target_data["info"]
|
||||
|
||||
# Only LED targets
|
||||
if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
|
||||
continue
|
||||
|
||||
entities.append(
|
||||
CSSSourceSelect(coordinator, target_id, entry.entry_id)
|
||||
)
|
||||
entities.append(
|
||||
BrightnessSourceSelect(coordinator, target_id, entry.entry_id)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class CSSSourceSelect(CoordinatorEntity, SelectEntity):
|
||||
"""Select entity for choosing a color strip source for an LED target."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_icon = "mdi:palette"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
target_id: str,
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._target_id = target_id
|
||||
self._entry_id = entry_id
|
||||
self._attr_unique_id = f"{target_id}_css_source"
|
||||
self._attr_translation_key = "color_strip_source"
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
return {"identifiers": {(DOMAIN, self._target_id)}}
|
||||
|
||||
@property
|
||||
def options(self) -> list[str]:
|
||||
if not self.coordinator.data:
|
||||
return []
|
||||
sources = self.coordinator.data.get("css_sources") or []
|
||||
return [s["name"] for s in sources]
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
if not self.coordinator.data:
|
||||
return None
|
||||
target_data = self.coordinator.data.get("targets", {}).get(self._target_id)
|
||||
if not target_data:
|
||||
return None
|
||||
current_id = target_data["info"].get("color_strip_source_id", "")
|
||||
sources = self.coordinator.data.get("css_sources") or []
|
||||
for s in sources:
|
||||
if s["id"] == current_id:
|
||||
return s["name"]
|
||||
return None
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
if not self.coordinator.data:
|
||||
return False
|
||||
return self._target_id in self.coordinator.data.get("targets", {})
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
source_id = self._name_to_id_map().get(option)
|
||||
if source_id is None:
|
||||
_LOGGER.error("CSS source not found: %s", option)
|
||||
return
|
||||
await self.coordinator.update_target(
|
||||
self._target_id, color_strip_source_id=source_id
|
||||
)
|
||||
|
||||
def _name_to_id_map(self) -> dict[str, str]:
|
||||
sources = (self.coordinator.data or {}).get("css_sources") or []
|
||||
return {s["name"]: s["id"] for s in sources}
|
||||
|
||||
|
||||
class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
|
||||
"""Select entity for choosing a brightness value source for an LED target."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_icon = "mdi:brightness-auto"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
target_id: str,
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._target_id = target_id
|
||||
self._entry_id = entry_id
|
||||
self._attr_unique_id = f"{target_id}_brightness_source"
|
||||
self._attr_translation_key = "brightness_source"
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
return {"identifiers": {(DOMAIN, self._target_id)}}
|
||||
|
||||
@property
|
||||
def options(self) -> list[str]:
|
||||
if not self.coordinator.data:
|
||||
return [NONE_OPTION]
|
||||
sources = self.coordinator.data.get("value_sources") or []
|
||||
return [NONE_OPTION] + [s["name"] for s in sources]
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
if not self.coordinator.data:
|
||||
return None
|
||||
target_data = self.coordinator.data.get("targets", {}).get(self._target_id)
|
||||
if not target_data:
|
||||
return None
|
||||
current_id = target_data["info"].get("brightness_value_source_id", "")
|
||||
if not current_id:
|
||||
return NONE_OPTION
|
||||
sources = self.coordinator.data.get("value_sources") or []
|
||||
for s in sources:
|
||||
if s["id"] == current_id:
|
||||
return s["name"]
|
||||
return NONE_OPTION
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
if not self.coordinator.data:
|
||||
return False
|
||||
return self._target_id in self.coordinator.data.get("targets", {})
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
if option == NONE_OPTION:
|
||||
source_id = ""
|
||||
else:
|
||||
name_map = {
|
||||
s["name"]: s["id"]
|
||||
for s in (self.coordinator.data or {}).get("value_sources") or []
|
||||
}
|
||||
source_id = name_map.get(option)
|
||||
if source_id is None:
|
||||
_LOGGER.error("Value source not found: %s", option)
|
||||
return
|
||||
await self.coordinator.update_target(
|
||||
self._target_id, brightness_value_source_id=source_id
|
||||
)
|
||||
@@ -1,273 +0,0 @@
|
||||
"""Sensor platform for LED Screen Controller."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
TARGET_TYPE_KEY_COLORS,
|
||||
DATA_COORDINATOR,
|
||||
DATA_WS_MANAGER,
|
||||
)
|
||||
from .coordinator import WLEDScreenControllerCoordinator
|
||||
from .ws_manager import KeyColorsWebSocketManager
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up LED Screen Controller sensors."""
|
||||
data = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
|
||||
ws_manager: KeyColorsWebSocketManager = data[DATA_WS_MANAGER]
|
||||
|
||||
entities: list[SensorEntity] = []
|
||||
if coordinator.data and "targets" in coordinator.data:
|
||||
for target_id, target_data in coordinator.data["targets"].items():
|
||||
entities.append(
|
||||
WLEDScreenControllerFPSSensor(coordinator, target_id, entry.entry_id)
|
||||
)
|
||||
entities.append(
|
||||
WLEDScreenControllerStatusSensor(
|
||||
coordinator, target_id, entry.entry_id
|
||||
)
|
||||
)
|
||||
|
||||
# Add color sensors for Key Colors targets
|
||||
info = target_data["info"]
|
||||
if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
|
||||
rectangles = target_data.get("rectangles", [])
|
||||
for rect in rectangles:
|
||||
entities.append(
|
||||
WLEDScreenControllerColorSensor(
|
||||
coordinator=coordinator,
|
||||
ws_manager=ws_manager,
|
||||
target_id=target_id,
|
||||
rectangle_name=rect["name"],
|
||||
entry_id=entry.entry_id,
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class WLEDScreenControllerFPSSensor(CoordinatorEntity, SensorEntity):
|
||||
"""FPS sensor for a LED Screen Controller target."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_native_unit_of_measurement = "FPS"
|
||||
_attr_icon = "mdi:speedometer"
|
||||
_attr_suggested_display_precision = 1
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
target_id: str,
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._target_id = target_id
|
||||
self._entry_id = entry_id
|
||||
self._attr_unique_id = f"{target_id}_fps"
|
||||
self._attr_translation_key = "fps"
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return device information."""
|
||||
return {"identifiers": {(DOMAIN, self._target_id)}}
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the FPS value."""
|
||||
target_data = self._get_target_data()
|
||||
if not target_data or not target_data.get("state"):
|
||||
return None
|
||||
state = target_data["state"]
|
||||
if not state.get("processing"):
|
||||
return None
|
||||
return state.get("fps_actual")
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return additional attributes."""
|
||||
target_data = self._get_target_data()
|
||||
if not target_data or not target_data.get("state"):
|
||||
return {}
|
||||
return {"fps_target": target_data["state"].get("fps_target")}
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return self._get_target_data() is not None
|
||||
|
||||
def _get_target_data(self) -> dict[str, Any] | None:
|
||||
if not self.coordinator.data:
|
||||
return None
|
||||
return self.coordinator.data.get("targets", {}).get(self._target_id)
|
||||
|
||||
|
||||
class WLEDScreenControllerStatusSensor(CoordinatorEntity, SensorEntity):
|
||||
"""Status sensor for a LED Screen Controller target."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_icon = "mdi:information-outline"
|
||||
_attr_device_class = SensorDeviceClass.ENUM
|
||||
_attr_options = ["processing", "idle", "error", "unavailable"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
target_id: str,
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._target_id = target_id
|
||||
self._entry_id = entry_id
|
||||
self._attr_unique_id = f"{target_id}_status"
|
||||
self._attr_translation_key = "status"
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return device information."""
|
||||
return {"identifiers": {(DOMAIN, self._target_id)}}
|
||||
|
||||
@property
|
||||
def native_value(self) -> str:
|
||||
"""Return the status."""
|
||||
target_data = self._get_target_data()
|
||||
if not target_data:
|
||||
return "unavailable"
|
||||
state = target_data.get("state")
|
||||
if not state:
|
||||
return "unavailable"
|
||||
if state.get("processing"):
|
||||
errors = state.get("errors", [])
|
||||
if errors:
|
||||
return "error"
|
||||
return "processing"
|
||||
return "idle"
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return self._get_target_data() is not None
|
||||
|
||||
def _get_target_data(self) -> dict[str, Any] | None:
|
||||
if not self.coordinator.data:
|
||||
return None
|
||||
return self.coordinator.data.get("targets", {}).get(self._target_id)
|
||||
|
||||
|
||||
class WLEDScreenControllerColorSensor(CoordinatorEntity, SensorEntity):
|
||||
"""Color sensor reporting the extracted screen color for a Key Colors rectangle."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_icon = "mdi:palette"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
ws_manager: KeyColorsWebSocketManager,
|
||||
target_id: str,
|
||||
rectangle_name: str,
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the color sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._target_id = target_id
|
||||
self._rectangle_name = rectangle_name
|
||||
self._ws_manager = ws_manager
|
||||
self._entry_id = entry_id
|
||||
self._unregister_ws: Callable[[], None] | None = None
|
||||
|
||||
sanitized = rectangle_name.lower().replace(" ", "_").replace("-", "_")
|
||||
self._attr_unique_id = f"{target_id}_{sanitized}_color"
|
||||
self._attr_translation_key = "rectangle_color"
|
||||
self._attr_translation_placeholders = {"rectangle_name": rectangle_name}
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return device information."""
|
||||
return {"identifiers": {(DOMAIN, self._target_id)}}
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register WS callback when entity is added."""
|
||||
await super().async_added_to_hass()
|
||||
self._unregister_ws = self._ws_manager.register_callback(
|
||||
self._target_id, self._handle_color_update
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unregister WS callback when entity is removed."""
|
||||
if self._unregister_ws:
|
||||
self._unregister_ws()
|
||||
self._unregister_ws = None
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
def _handle_color_update(self, colors: dict) -> None:
|
||||
"""Handle incoming color update from WebSocket."""
|
||||
if self._rectangle_name in colors:
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the hex color string (e.g. #FF8800)."""
|
||||
color = self._get_color()
|
||||
if color is None:
|
||||
return None
|
||||
return f"#{color['r']:02X}{color['g']:02X}{color['b']:02X}"
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return r, g, b, brightness as attributes."""
|
||||
color = self._get_color()
|
||||
if color is None:
|
||||
return {}
|
||||
r, g, b = color["r"], color["g"], color["b"]
|
||||
brightness = int(0.299 * r + 0.587 * g + 0.114 * b)
|
||||
return {
|
||||
"r": r,
|
||||
"g": g,
|
||||
"b": b,
|
||||
"brightness": brightness,
|
||||
"rgb_color": [r, g, b],
|
||||
}
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return self._get_target_data() is not None
|
||||
|
||||
def _get_color(self) -> dict[str, int] | None:
|
||||
"""Get the current color for this rectangle from WS manager."""
|
||||
target_data = self._get_target_data()
|
||||
if not target_data or not target_data.get("state"):
|
||||
return None
|
||||
if not target_data["state"].get("processing"):
|
||||
return None
|
||||
colors = self._ws_manager.get_latest_colors(self._target_id)
|
||||
return colors.get(self._rectangle_name)
|
||||
|
||||
def _get_target_data(self) -> dict[str, Any] | None:
|
||||
if not self.coordinator.data:
|
||||
return None
|
||||
return self.coordinator.data.get("targets", {}).get(self._target_id)
|
||||
@@ -1,19 +0,0 @@
|
||||
set_leds:
|
||||
name: Set LEDs
|
||||
description: Push segment data to an api_input color strip source
|
||||
fields:
|
||||
source_id:
|
||||
name: Source ID
|
||||
description: The api_input CSS source ID (e.g., css_abc12345)
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
segments:
|
||||
name: Segments
|
||||
description: >
|
||||
List of segment objects. Each segment has: start (int), length (int),
|
||||
mode ("solid"/"per_pixel"/"gradient"), color ([R,G,B] for solid),
|
||||
colors ([[R,G,B],...] for per_pixel/gradient)
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
@@ -1,91 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Set up LED Screen Controller",
|
||||
"description": "Enter the URL and API key for your LED Screen Controller server.",
|
||||
"data": {
|
||||
"server_name": "Server Name",
|
||||
"server_url": "Server URL",
|
||||
"api_key": "API Key"
|
||||
},
|
||||
"data_description": {
|
||||
"server_name": "Display name for this server in Home Assistant",
|
||||
"server_url": "URL of your LED Screen Controller server (e.g., http://192.168.1.100:8080)",
|
||||
"api_key": "API key from your server's configuration file"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to server.",
|
||||
"invalid_api_key": "Invalid API key.",
|
||||
"unknown": "Unexpected error occurred."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This server is already configured."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"activate_scene": {
|
||||
"name": "{scene_name}"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"api_input_light": {
|
||||
"name": "Light"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"processing": {
|
||||
"name": "Processing"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"fps": {
|
||||
"name": "FPS"
|
||||
},
|
||||
"status": {
|
||||
"name": "Status",
|
||||
"state": {
|
||||
"processing": "Processing",
|
||||
"idle": "Idle",
|
||||
"error": "Error",
|
||||
"unavailable": "Unavailable"
|
||||
}
|
||||
},
|
||||
"rectangle_color": {
|
||||
"name": "{rectangle_name} Color"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"brightness": {
|
||||
"name": "Brightness"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"color_strip_source": {
|
||||
"name": "Color Strip Source"
|
||||
},
|
||||
"brightness_source": {
|
||||
"name": "Brightness Source"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_leds": {
|
||||
"name": "Set LEDs",
|
||||
"description": "Push segment data to an api_input color strip source.",
|
||||
"fields": {
|
||||
"source_id": {
|
||||
"name": "Source ID",
|
||||
"description": "The api_input CSS source ID (e.g., css_abc12345)."
|
||||
},
|
||||
"segments": {
|
||||
"name": "Segments",
|
||||
"description": "List of segment objects with start, length, mode, and color/colors fields."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
"""Switch platform for LED Screen Controller."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, DATA_COORDINATOR
|
||||
from .coordinator import WLEDScreenControllerCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up LED Screen Controller switches."""
|
||||
data = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
|
||||
|
||||
entities = []
|
||||
if coordinator.data and "targets" in coordinator.data:
|
||||
for target_id, target_data in coordinator.data["targets"].items():
|
||||
entities.append(
|
||||
WLEDScreenControllerSwitch(coordinator, target_id, entry.entry_id)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class WLEDScreenControllerSwitch(CoordinatorEntity, SwitchEntity):
|
||||
"""Representation of a LED Screen Controller target processing switch."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
target_id: str,
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(coordinator)
|
||||
self._target_id = target_id
|
||||
self._entry_id = entry_id
|
||||
self._attr_unique_id = f"{target_id}_processing"
|
||||
self._attr_translation_key = "processing"
|
||||
self._attr_icon = "mdi:television-ambient-light"
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return device information."""
|
||||
return {"identifiers": {(DOMAIN, self._target_id)}}
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if processing is active."""
|
||||
target_data = self._get_target_data()
|
||||
if not target_data or not target_data.get("state"):
|
||||
return False
|
||||
return target_data["state"].get("processing", False)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return self._get_target_data() is not None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return additional state attributes."""
|
||||
target_data = self._get_target_data()
|
||||
if not target_data:
|
||||
return {}
|
||||
|
||||
attrs: dict[str, Any] = {"target_id": self._target_id}
|
||||
state = target_data.get("state") or {}
|
||||
metrics = target_data.get("metrics") or {}
|
||||
|
||||
if state:
|
||||
attrs["fps_target"] = state.get("fps_target")
|
||||
attrs["fps_actual"] = state.get("fps_actual")
|
||||
|
||||
if metrics:
|
||||
attrs["frames_processed"] = metrics.get("frames_processed")
|
||||
attrs["errors_count"] = metrics.get("errors_count")
|
||||
attrs["uptime_seconds"] = metrics.get("uptime_seconds")
|
||||
|
||||
return attrs
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Start processing."""
|
||||
await self.coordinator.start_processing(self._target_id)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Stop processing."""
|
||||
await self.coordinator.stop_processing(self._target_id)
|
||||
|
||||
def _get_target_data(self) -> dict[str, Any] | None:
|
||||
"""Get target data from coordinator."""
|
||||
if not self.coordinator.data:
|
||||
return None
|
||||
return self.coordinator.data.get("targets", {}).get(self._target_id)
|
||||
@@ -1,75 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Set up LED Screen Controller",
|
||||
"description": "Enter the URL and API key for your LED Screen Controller server.",
|
||||
"data": {
|
||||
"server_name": "Server Name",
|
||||
"server_url": "Server URL",
|
||||
"api_key": "API Key"
|
||||
},
|
||||
"data_description": {
|
||||
"server_name": "Display name for this server in Home Assistant",
|
||||
"server_url": "URL of your LED Screen Controller server (e.g., http://192.168.1.100:8080)",
|
||||
"api_key": "API key from your server's configuration file"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to server.",
|
||||
"invalid_api_key": "Invalid API key.",
|
||||
"unknown": "Unexpected error occurred."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This server is already configured."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"activate_scene": {
|
||||
"name": "{scene_name}"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"api_input_light": {
|
||||
"name": "Light"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"processing": {
|
||||
"name": "Processing"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"fps": {
|
||||
"name": "FPS"
|
||||
},
|
||||
"status": {
|
||||
"name": "Status",
|
||||
"state": {
|
||||
"processing": "Processing",
|
||||
"idle": "Idle",
|
||||
"error": "Error",
|
||||
"unavailable": "Unavailable"
|
||||
}
|
||||
},
|
||||
"rectangle_color": {
|
||||
"name": "{rectangle_name} Color"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"brightness": {
|
||||
"name": "Brightness"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"color_strip_source": {
|
||||
"name": "Color Strip Source"
|
||||
},
|
||||
"brightness_source": {
|
||||
"name": "Brightness Source"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Настройка LED Screen Controller",
|
||||
"description": "Введите URL и API-ключ вашего сервера LED Screen Controller.",
|
||||
"data": {
|
||||
"server_name": "Имя сервера",
|
||||
"server_url": "URL сервера",
|
||||
"api_key": "API-ключ"
|
||||
},
|
||||
"data_description": {
|
||||
"server_name": "Отображаемое имя сервера в Home Assistant",
|
||||
"server_url": "URL сервера LED Screen Controller (например, http://192.168.1.100:8080)",
|
||||
"api_key": "API-ключ из конфигурационного файла сервера"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Не удалось подключиться к серверу.",
|
||||
"invalid_api_key": "Неверный API-ключ.",
|
||||
"unknown": "Произошла непредвиденная ошибка."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Этот сервер уже настроен."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"activate_scene": {
|
||||
"name": "{scene_name}"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"api_input_light": {
|
||||
"name": "Подсветка"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"processing": {
|
||||
"name": "Обработка"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"fps": {
|
||||
"name": "FPS"
|
||||
},
|
||||
"status": {
|
||||
"name": "Статус",
|
||||
"state": {
|
||||
"processing": "Обработка",
|
||||
"idle": "Ожидание",
|
||||
"error": "Ошибка",
|
||||
"unavailable": "Недоступен"
|
||||
}
|
||||
},
|
||||
"rectangle_color": {
|
||||
"name": "{rectangle_name} Цвет"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"brightness": {
|
||||
"name": "Яркость"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"color_strip_source": {
|
||||
"name": "Источник цветовой полосы"
|
||||
},
|
||||
"brightness_source": {
|
||||
"name": "Источник яркости"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
"""WebSocket connection manager for Key Colors target color streams."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import WS_RECONNECT_DELAY, WS_MAX_RECONNECT_DELAY
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KeyColorsWebSocketManager:
|
||||
"""Manages WebSocket connections for Key Colors target color streams."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
server_url: str,
|
||||
api_key: str,
|
||||
) -> None:
|
||||
self._hass = hass
|
||||
self._server_url = server_url
|
||||
self._api_key = api_key
|
||||
self._connections: dict[str, asyncio.Task] = {}
|
||||
self._callbacks: dict[str, list[Callable]] = {}
|
||||
self._latest_colors: dict[str, dict[str, dict[str, int]]] = {}
|
||||
self._shutting_down = False
|
||||
|
||||
def _get_ws_url(self, target_id: str) -> str:
|
||||
"""Build WebSocket URL for a target."""
|
||||
ws_base = self._server_url.replace("http://", "ws://").replace(
|
||||
"https://", "wss://"
|
||||
)
|
||||
return f"{ws_base}/api/v1/output-targets/{target_id}/ws?token={self._api_key}"
|
||||
|
||||
async def start_listening(self, target_id: str) -> None:
|
||||
"""Start WebSocket connection for a target."""
|
||||
if target_id in self._connections:
|
||||
return
|
||||
task = self._hass.async_create_background_task(
|
||||
self._ws_loop(target_id),
|
||||
f"wled_screen_controller_ws_{target_id}",
|
||||
)
|
||||
self._connections[target_id] = task
|
||||
|
||||
async def stop_listening(self, target_id: str) -> None:
|
||||
"""Stop WebSocket connection for a target."""
|
||||
task = self._connections.pop(target_id, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
self._latest_colors.pop(target_id, None)
|
||||
|
||||
def register_callback(
|
||||
self, target_id: str, callback: Callable
|
||||
) -> Callable[[], None]:
|
||||
"""Register a callback for color updates. Returns unregister function."""
|
||||
self._callbacks.setdefault(target_id, []).append(callback)
|
||||
|
||||
def unregister() -> None:
|
||||
cbs = self._callbacks.get(target_id)
|
||||
if cbs and callback in cbs:
|
||||
cbs.remove(callback)
|
||||
|
||||
return unregister
|
||||
|
||||
def get_latest_colors(self, target_id: str) -> dict[str, dict[str, int]]:
|
||||
"""Get latest colors for a target."""
|
||||
return self._latest_colors.get(target_id, {})
|
||||
|
||||
async def _ws_loop(self, target_id: str) -> None:
|
||||
"""WebSocket connection loop with reconnection."""
|
||||
delay = WS_RECONNECT_DELAY
|
||||
session = async_get_clientsession(self._hass)
|
||||
|
||||
while not self._shutting_down:
|
||||
try:
|
||||
url = self._get_ws_url(target_id)
|
||||
async with session.ws_connect(url) as ws:
|
||||
delay = WS_RECONNECT_DELAY # reset on successful connect
|
||||
_LOGGER.debug("WS connected for target %s", target_id)
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
self._handle_message(target_id, msg.data)
|
||||
elif msg.type in (
|
||||
aiohttp.WSMsgType.CLOSED,
|
||||
aiohttp.WSMsgType.ERROR,
|
||||
):
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as err:
|
||||
_LOGGER.debug("WS connection error for %s: %s", target_id, err)
|
||||
except Exception as err:
|
||||
_LOGGER.error("Unexpected WS error for %s: %s", target_id, err)
|
||||
|
||||
if self._shutting_down:
|
||||
break
|
||||
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, WS_MAX_RECONNECT_DELAY)
|
||||
|
||||
def _handle_message(self, target_id: str, raw: str) -> None:
|
||||
"""Handle incoming WebSocket message."""
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
|
||||
if data.get("type") != "colors_update":
|
||||
return
|
||||
|
||||
colors: dict[str, Any] = data.get("colors", {})
|
||||
self._latest_colors[target_id] = colors
|
||||
|
||||
for cb in self._callbacks.get(target_id, []):
|
||||
try:
|
||||
cb(colors)
|
||||
except Exception:
|
||||
_LOGGER.exception("Error in WS color callback for %s", target_id)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Stop all WebSocket connections."""
|
||||
self._shutting_down = True
|
||||
for target_id in list(self._connections):
|
||||
await self.stop_listening(target_id)
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# WLED Screen Controller API Documentation
|
||||
# LedGrab API Documentation
|
||||
|
||||
Complete REST API reference for the WLED Screen Controller server.
|
||||
Complete REST API reference for the LedGrab server.
|
||||
|
||||
**Base URL:** `http://localhost:8080`
|
||||
**API Version:** v1
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# BLE LED Controllers — Investigation & Implementation Notes
|
||||
|
||||
Reference for anyone touching the BLE device provider (`server/src/ledgrab/core/devices/ble_*`). Captures the protocol quirks, Windows/bleak traps, and hardware lockdown we hit while bringing up SP110E / Triones / Zengge / Govee support.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
BLEDeviceProvider → BLEClient → BLETransport (desktop: bleak, Android: Kotlin BleBridge via Chaquopy)
|
||||
│
|
||||
└─ BLEProtocol (family-specific wire bytes: sp110e.py, triones.py, zengge.py, govee.py)
|
||||
```
|
||||
|
||||
- One `BLEProtocol` dataclass per controller family. Each supplies GATT UUIDs, write type (with/without response), `encode_color` / `encode_power` functions, name prefixes for discovery, and an optional `init_writes` handshake sequence.
|
||||
- `BLEClient` is whole-strip only. `send_pixels()` averages incoming pixel arrays and emits one solid color per frame — none of these protocols support per-pixel streaming.
|
||||
- Discovery auto-detects the family via advertised name prefix first, falls back to service UUID matching. The detected family is returned on `DiscoveredDevice.ble_family` and preselected in the UI.
|
||||
- The settings modal lets users change the family after creation — wrong family → writes go to a characteristic the device ignores → strip stays dark.
|
||||
|
||||
## Protocol Quirks
|
||||
|
||||
### SP110E / SP108E (critical handshake)
|
||||
|
||||
The controller **silently tears the GATT link down within ~1 second of connect** unless a two-write handshake arrives immediately:
|
||||
|
||||
```
|
||||
Write 01 00 → characteristic FFE2
|
||||
Write 01 B7 E3 D5 → characteristic FFE1
|
||||
```
|
||||
|
||||
Without this, the first real write later hangs for 30 s because bleak thinks the link is up but the peripheral has already dropped it. We carry these writes in `PROTOCOL.init_writes` and execute them from `BLEClient.connect()` right after GATT open.
|
||||
|
||||
Color frame is **4 bytes** (`RR GG BB 1E`), not 5 — the earlier implementation had a stray `0x00` padding byte that the device tolerated but isn't documented.
|
||||
|
||||
Source: [mbullington's reverse-engineering gist](https://gist.github.com/mbullington/37957501a07ad065b67d4e8d39bfe012).
|
||||
|
||||
### Triones / Zengge / Govee
|
||||
|
||||
No init handshake required. Color frames and command bytes documented inline in each protocol module. Notable: Zengge and SP110E share service UUID `FFE0/FFE1`, so name-based identification is the only reliable way to tell them apart. In `_register_builtins()`, SP110E is registered first so it wins the `identify_family_by_service_uuids` tie by default — change this if the user base flips.
|
||||
|
||||
## bleak + Windows WinRT Traps
|
||||
|
||||
These bit us hard. All are now worked around, but future BLE work should keep them in mind.
|
||||
|
||||
### 1. `asyncio.wait_for` hangs forever on WinRT
|
||||
|
||||
`BleakClient.connect()` / `write_gatt_char()` wrap WinRT `IAsyncOperation`s. When asyncio tries to cancel them (as `wait_for` does on timeout), the WinRT task **never finishes cancelling**, so `wait_for` itself blocks forever while awaiting the cancellation. Symptom: log stops with no timeout error, process is alive but wedged.
|
||||
|
||||
**Fix**: `_bounded_await()` in [ble_transport.py](../server/src/ledgrab/core/devices/ble_transport.py) uses `asyncio.wait()` instead, which returns on timeout without awaiting pending tasks. Orphans the hanging WinRT task but frees the caller.
|
||||
|
||||
### 2. Connect by raw MAC string fails on Windows
|
||||
|
||||
Passing `BleakClient("AA:BB:CC:DD:EE:FF")` makes WinRT guess the address type (public vs random static vs random resolvable). Guesses wrong → connect silently times out. Symptom: `TimeoutError: BLE connect to ... exceeded 10.0s` with no other signal.
|
||||
|
||||
**Fix**: Always pre-scan with `BleakScanner.find_device_by_address()` and pass the returned `BLEDevice` object to `BleakClient`. Costs ~400 ms but makes connect reliable.
|
||||
|
||||
### 3. Client-side fetch timeout too short for BLE target start
|
||||
|
||||
The target-start endpoint does a ~5 s pre-scan + up to 10 s GATT connect + init handshake. Default `fetchWithAuth` has a 10 s timeout and 3× retry, so the UI was aborting and retrying concurrent `/start` requests into the server.
|
||||
|
||||
**Fix**: `startTargetProcessing` overrides `timeout: 30000, retry: false`.
|
||||
|
||||
### 4. `Start-Process -WindowStyle Hidden` from bash/WSL strips handles
|
||||
|
||||
When `restart.ps1` is invoked from Git-Bash / WSL, `Start-Process` inherited handles cause the child uvicorn to exit immediately. Stream redirection fixes it.
|
||||
|
||||
**Fix**: `restart.ps1` always uses `-RedirectStandardOutput`/`-RedirectStandardError` to a temp log. Failed startups dump the stderr tail to the caller so root cause is visible.
|
||||
|
||||
## Vendor Lockdown (the dead end)
|
||||
|
||||
Some controllers — notably the one we tested, advertising as `AlexTable` at `16:61:05:70:68:44` — **only accept connections from the vendor phone app**. Diagnostic sequence:
|
||||
|
||||
| Test | Result | Meaning |
|
||||
| --- | --- | --- |
|
||||
| LedGrab `BleakClient.connect()` | 10 s timeout | Windows can't connect |
|
||||
| Windows "Bluetooth LE Explorer" | Hangs on connect | Same Windows stack as bleak — not our bug |
|
||||
| Phone **OS** Bluetooth Settings | Can't connect | Phone OS uses generic BLE stack — also fails |
|
||||
| Phone **LED Hue** app | Connects fine | Vendor app is the *only* working client |
|
||||
|
||||
At this point, further Windows/bleak tweaks have no effect. The peripheral firmware rejects generic GATT connects and only stays connected when the LED Hue app emits its vendor-specific handshake. To unlock such a controller from LedGrab you'd need to:
|
||||
|
||||
1. Enable **Developer Options → Bluetooth HCI snoop log** on Android.
|
||||
2. Reproduce the LED Hue flow (connect → color change → disconnect).
|
||||
3. `adb bugreport bugreport.zip`; extract `btsnoop_hci.log`.
|
||||
4. Open in Wireshark; identify the vendor handshake bytes written during connect.
|
||||
5. Add them to the protocol's `init_writes`.
|
||||
|
||||
Alternatively, replace the BLE controller hardware with **WLED on ESP32** — $3, fully supported, vastly more capable.
|
||||
|
||||
## Frontend
|
||||
|
||||
- BLE family picker uses the project's shared `IconSelect` grid (project rule — see [CLAUDE.md](../CLAUDE.md): "NEVER use plain HTML `<select>`").
|
||||
- Registry in `device-discovery.ts` is keyed by element ID so both the add-device and settings modals get their own IconSelect instance. Helpers: `ensureBleFamilyIconSelect(selectId, onChange?)` / `destroyBleFamilyIconSelect(selectId)`.
|
||||
- Govee AES key row is conditionally visible: only shows when the selected family is `govee`.
|
||||
|
||||
## HAOS Integration Pair
|
||||
|
||||
The sister repo `ledgrab-haos-integration` had its own WebSocket auth bug that surfaced during this session — the integration still used the deprecated `?token=<key>` query param instead of the new first-message handshake. Fixed in [v0.2.1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab-haos-integration). Unrelated to BLE but shared debugging time.
|
||||
|
||||
## Tests
|
||||
|
||||
`server/tests/test_ble_protocols.py` and `server/tests/test_ble_client.py` use a `FakeTransport` that logs every write with its `char_uuid`, so protocol wire formats and the init handshake are unit-testable without hardware or bleak installed. New protocol additions should extend these.
|
||||
|
||||
## Files
|
||||
|
||||
- [ble_client.py](../server/src/ledgrab/core/devices/ble_client.py) — provider-facing class; runs init handshake on connect; reconnect backoff.
|
||||
- [ble_transport.py](../server/src/ledgrab/core/devices/ble_transport.py) — bleak desktop transport; `_bounded_await` helper; per-write char override.
|
||||
- [android_ble_transport.py](../server/src/ledgrab/core/devices/android_ble_transport.py) — Chaquopy/Kotlin transport; currently ignores `char_uuid` override (bridge binds a single write characteristic).
|
||||
- [ble_provider.py](../server/src/ledgrab/core/devices/ble_provider.py) — discovery, family detection, `set_color` / `set_power` short-lived sessions.
|
||||
- [ble_protocols/](../server/src/ledgrab/core/devices/ble_protocols/) — one file per family (pure byte-encoding functions, no BLE deps).
|
||||
- [BleBridge.kt](../android/app/src/main/java/com/ledgrab/android/BleBridge.kt) — Android-side BLE GATT wrapper exposed to Python via Chaquopy.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "WLED Screen Controller",
|
||||
"render_readme": true,
|
||||
"country": ["US"],
|
||||
"homeassistant": "2023.1.0"
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
# Feature Context: Demo Mode
|
||||
|
||||
## Current State
|
||||
Starting implementation. No changes made yet.
|
||||
|
||||
## Key Architecture Notes
|
||||
- `EngineRegistry` (class-level dict) holds capture engines, auto-registered in `capture_engines/__init__.py`
|
||||
- `AudioEngineRegistry` (class-level dict) holds audio engines, auto-registered in `audio/__init__.py`
|
||||
- `LEDDeviceProvider` instances registered via `register_provider()` in `led_client.py`
|
||||
- Already has `MockDeviceProvider` + `MockClient` (device type "mock") for testing
|
||||
- Config is `pydantic_settings.BaseSettings` in `config.py`, loaded from YAML + env vars
|
||||
- Frontend header in `templates/index.html` line 27-31: title + version badge
|
||||
- Frontend bundle: `cd server && npm run build` (esbuild)
|
||||
- Data stored as JSON in `data/` directory, paths configured via `StorageConfig`
|
||||
|
||||
## Temporary Workarounds
|
||||
- None yet
|
||||
|
||||
## Cross-Phase Dependencies
|
||||
- Phase 1 (config flag) is foundational — all other phases depend on `is_demo_mode()`
|
||||
- Phase 2 & 3 (engines) can be done independently of each other
|
||||
- Phase 4 (seed data) depends on knowing what entities to create, which is informed by phases 2-3
|
||||
- Phase 5 (frontend) depends on the system info API field from phase 1
|
||||
- Phase 6 (engine resolution) depends on engines existing from phases 2-3
|
||||
|
||||
## Implementation Notes
|
||||
- Demo mode activated via `WLED_DEMO=true` env var or `demo: true` in YAML config
|
||||
- Isolated data directory `data/demo/` keeps demo entities separate from real config
|
||||
- Demo engines use `ENGINE_TYPE = "demo"` and are always registered but return `is_available() = True` only in demo mode
|
||||
- The existing `MockDeviceProvider`/`MockClient` can be reused or extended for demo device output
|
||||
@@ -1,44 +0,0 @@
|
||||
# Feature: Demo Mode
|
||||
|
||||
**Branch:** `feature/demo-mode`
|
||||
**Base branch:** `master`
|
||||
**Created:** 2026-03-20
|
||||
**Status:** 🟡 In Progress
|
||||
**Strategy:** Big Bang
|
||||
**Mode:** Automated
|
||||
**Execution:** Orchestrator
|
||||
|
||||
## Summary
|
||||
Add a demo mode that allows users to explore and test the app without real hardware. Virtual capture engines, audio engines, and device providers replace real hardware. An isolated data directory with seed data provides a fully populated sandbox. A visual indicator in the UI makes it clear the app is running in demo mode.
|
||||
|
||||
## Build & Test Commands
|
||||
- **Build (frontend):** `cd server && npm run build`
|
||||
- **Typecheck (frontend):** `cd server && npm run typecheck`
|
||||
- **Test (backend):** `cd server && python -m pytest ../tests/ -x`
|
||||
- **Server start:** `cd server && python -m wled_controller.main`
|
||||
|
||||
## Phases
|
||||
|
||||
- [x] Phase 1: Demo Mode Config & Flag [domain: backend] → [subplan](./phase-1-config-flag.md)
|
||||
- [x] Phase 2: Virtual Capture Engine [domain: backend] → [subplan](./phase-2-virtual-capture-engine.md)
|
||||
- [x] Phase 3: Virtual Audio Engine [domain: backend] → [subplan](./phase-3-virtual-audio-engine.md)
|
||||
- [x] Phase 4: Demo Device Provider & Seed Data [domain: backend] → [subplan](./phase-4-demo-device-seed-data.md)
|
||||
- [x] Phase 5: Frontend Demo Indicator & Sandbox UX [domain: fullstack] → [subplan](./phase-5-frontend-demo-ux.md)
|
||||
- [x] Phase 6: Demo-only Engine Resolution [domain: backend] → [subplan](./phase-6-engine-resolution.md)
|
||||
|
||||
## Phase Progress Log
|
||||
|
||||
| Phase | Domain | Status | Review | Build | Committed |
|
||||
|-------|--------|--------|--------|-------|-----------|
|
||||
| Phase 1: Config & Flag | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
| Phase 2: Virtual Capture Engine | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
| Phase 3: Virtual Audio Engine | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
| Phase 4: Demo Device & Seed Data | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
| Phase 5: Frontend Demo UX | fullstack | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
| Phase 6: Engine Resolution | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
|
||||
## Final Review
|
||||
- [ ] Comprehensive code review
|
||||
- [ ] Full build passes
|
||||
- [ ] Full test suite passes
|
||||
- [ ] Merged to `master`
|
||||
@@ -1,42 +0,0 @@
|
||||
# Phase 1: Demo Mode Config & Flag
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Add a `demo` boolean flag to the application configuration and expose it to the frontend via the system info API. When demo mode is active, the server uses an isolated data directory so demo entities don't pollute real user data.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Add `demo: bool = False` field to `Config` class in `config.py`
|
||||
- [ ] Task 2: Add a module-level helper `is_demo_mode() -> bool` in `config.py` for easy import
|
||||
- [ ] Task 3: Modify `StorageConfig` path resolution: when `demo=True`, prefix all storage paths with `data/demo/` instead of `data/`
|
||||
- [ ] Task 4: Expose `demo_mode: bool` in the existing `GET /api/v1/system/info` endpoint response
|
||||
- [ ] Task 5: Add `WLED_DEMO=true` env var support (already handled by pydantic-settings env prefix `WLED_`)
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/config.py` — Add `demo` field, `is_demo_mode()` helper, storage path override
|
||||
- `server/src/wled_controller/api/routes/system.py` — Add `demo_mode` to system info response
|
||||
- `server/src/wled_controller/api/schemas/system.py` — Add `demo_mode` field to response schema
|
||||
|
||||
## Acceptance Criteria
|
||||
- `Config(demo=True)` is accepted; default is `False`
|
||||
- `WLED_DEMO=true` activates demo mode
|
||||
- `is_demo_mode()` returns the correct value
|
||||
- When demo mode is on, all storage files resolve under `data/demo/`
|
||||
- `GET /api/v1/system/info` includes `demo_mode: true/false`
|
||||
|
||||
## Notes
|
||||
- The env var will be `WLED_DEMO` because of `env_prefix="WLED_"` in pydantic-settings
|
||||
- Storage path override should happen at `Config` construction time, not lazily
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
@@ -1,48 +0,0 @@
|
||||
# Phase 2: Virtual Capture Engine
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Create a `DemoCaptureEngine` that provides virtual displays and produces animated test pattern frames, allowing screen capture workflows to function in demo mode without real monitors.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Create `server/src/wled_controller/core/capture_engines/demo_engine.py` with `DemoCaptureEngine` and `DemoCaptureStream`
|
||||
- [ ] Task 2: `DemoCaptureEngine.ENGINE_TYPE = "demo"`, `ENGINE_PRIORITY = 1000` (highest in demo mode)
|
||||
- [ ] Task 3: `is_available()` returns `True` only when `is_demo_mode()` is True
|
||||
- [ ] Task 4: `get_available_displays()` returns 3 virtual displays:
|
||||
- "Demo Display 1080p" (1920×1080)
|
||||
- "Demo Ultrawide" (3440×1440)
|
||||
- "Demo Portrait" (1080×1920)
|
||||
- [ ] Task 5: `DemoCaptureStream.capture_frame()` produces animated test patterns:
|
||||
- Horizontally scrolling rainbow gradient (simple, visually clear)
|
||||
- Uses `time.time()` for animation so frames change over time
|
||||
- Returns proper `ScreenCapture` with RGB numpy array
|
||||
- [ ] Task 6: Register `DemoCaptureEngine` in `capture_engines/__init__.py`
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/core/capture_engines/demo_engine.py` — New file: DemoCaptureEngine + DemoCaptureStream
|
||||
- `server/src/wled_controller/core/capture_engines/__init__.py` — Register DemoCaptureEngine
|
||||
|
||||
## Acceptance Criteria
|
||||
- `DemoCaptureEngine.is_available()` is True only in demo mode
|
||||
- Virtual displays appear in the display list API when in demo mode
|
||||
- `capture_frame()` returns valid RGB frames that change over time
|
||||
- Engine is properly registered in EngineRegistry
|
||||
|
||||
## Notes
|
||||
- Test patterns should be computationally cheap (no heavy image processing)
|
||||
- Use numpy operations for pattern generation (vectorized, fast)
|
||||
- Frame dimensions must match the virtual display dimensions
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
@@ -1,47 +0,0 @@
|
||||
# Phase 3: Virtual Audio Engine
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Create a `DemoAudioEngine` that provides virtual audio devices and produces synthetic audio data, enabling audio-reactive visualizations in demo mode.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Create `server/src/wled_controller/core/audio/demo_engine.py` with `DemoAudioEngine` and `DemoAudioCaptureStream`
|
||||
- [ ] Task 2: `DemoAudioEngine.ENGINE_TYPE = "demo"`, `ENGINE_PRIORITY = 1000`
|
||||
- [ ] Task 3: `is_available()` returns `True` only when `is_demo_mode()` is True
|
||||
- [ ] Task 4: `enumerate_devices()` returns 2 virtual devices:
|
||||
- "Demo Microphone" (input, not loopback)
|
||||
- "Demo System Audio" (loopback)
|
||||
- [ ] Task 5: `DemoAudioCaptureStream` implements:
|
||||
- `channels = 2`, `sample_rate = 44100`, `chunk_size = 1024`
|
||||
- `read_chunk()` produces synthetic audio: a mix of sine waves with slowly varying frequencies to simulate music-like beat patterns
|
||||
- Returns proper float32 ndarray
|
||||
- [ ] Task 6: Register `DemoAudioEngine` in `audio/__init__.py`
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/core/audio/demo_engine.py` — New file: DemoAudioEngine + DemoAudioCaptureStream
|
||||
- `server/src/wled_controller/core/audio/__init__.py` — Register DemoAudioEngine
|
||||
|
||||
## Acceptance Criteria
|
||||
- `DemoAudioEngine.is_available()` is True only in demo mode
|
||||
- Virtual audio devices appear in audio device enumeration when in demo mode
|
||||
- `read_chunk()` returns valid float32 audio data that varies over time
|
||||
- Audio analyzer produces non-trivial frequency band data from the synthetic signal
|
||||
|
||||
## Notes
|
||||
- Synthetic audio should produce interesting FFT results (multiple frequencies, amplitude modulation)
|
||||
- Keep it computationally lightweight
|
||||
- Must conform to `AudioCaptureStreamBase` interface exactly
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
@@ -1,54 +0,0 @@
|
||||
# Phase 4: Demo Device Provider & Seed Data
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Create a demo device provider that exposes discoverable virtual LED devices, and build a seed data generator that populates the demo data directory with sample entities on first run.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Create `server/src/wled_controller/core/devices/demo_provider.py` — `DemoDeviceProvider` extending `LEDDeviceProvider`:
|
||||
- `device_type = "demo"`
|
||||
- `capabilities = {"manual_led_count", "power_control", "brightness_control", "static_color"}`
|
||||
- `create_client()` returns a `MockClient` (reuse existing)
|
||||
- `discover()` returns 3 pre-defined virtual devices:
|
||||
- "Demo LED Strip" (60 LEDs, ip="demo-strip")
|
||||
- "Demo LED Matrix" (256 LEDs / 16×16, ip="demo-matrix")
|
||||
- "Demo LED Ring" (24 LEDs, ip="demo-ring")
|
||||
- `check_health()` always returns online with simulated ~2ms latency
|
||||
- `validate_device()` returns `{"led_count": <from url>}`
|
||||
- [ ] Task 2: Register `DemoDeviceProvider` in `led_client.py` `_register_builtin_providers()`
|
||||
- [ ] Task 3: Create `server/src/wled_controller/core/demo_seed.py` — seed data generator:
|
||||
- Function `seed_demo_data(storage_config: StorageConfig)` that checks if demo data dir is empty and populates it
|
||||
- Seed entities: 3 devices (matching discover results), 2 output targets, 2 picture sources (using demo engine), 2 CSS sources (gradient + color_cycle), 1 audio source (using demo engine), 1 scene preset, 1 automation
|
||||
- Use proper ID formats matching existing conventions (e.g., `dev_<hex>`, `tgt_<hex>`, etc.)
|
||||
- [ ] Task 4: Call `seed_demo_data()` during server startup in `main.py` when demo mode is active (before stores are loaded)
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/core/devices/demo_provider.py` — New: DemoDeviceProvider
|
||||
- `server/src/wled_controller/core/devices/led_client.py` — Register DemoDeviceProvider
|
||||
- `server/src/wled_controller/core/demo_seed.py` — New: seed data generator
|
||||
- `server/src/wled_controller/main.py` — Call seed on demo startup
|
||||
|
||||
## Acceptance Criteria
|
||||
- Demo devices appear in discovery results when in demo mode
|
||||
- Seed data populates `data/demo/` with valid JSON files on first demo run
|
||||
- Subsequent demo runs don't overwrite existing demo data
|
||||
- All seeded entities load correctly in stores
|
||||
|
||||
## Notes
|
||||
- Seed data must match the exact schema expected by each store (look at existing JSON files for format)
|
||||
- Use the entity dataclass `to_dict()` / store patterns to generate valid data
|
||||
- Demo discovery should NOT appear when not in demo mode
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user