Compare commits
207 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c096db639 | |||
| 39b0554444 | |||
| 6745e25b20 | |||
| 126d8f2449 | |||
| e584235676 | |||
| b43f821046 | |||
| 077c99c7d1 | |||
| ae74cca132 | |||
| 77284e8e7b | |||
| ff1ff06cb5 | |||
| 3dd1ac3f0d | |||
| 6e1dd2111d | |||
| 9a0137fa4c | |||
| 4a0927521a | |||
| 25c613c5cb | |||
| 726f39e2ba | |||
| 1ac4a0f66d | |||
| 1afe7d6fcc | |||
| 17dd2e02ba | |||
| 7a12f39f49 | |||
| dd43f3836d | |||
| d32961085d | |||
| 6cd5e057da | |||
| 81b18089e1 | |||
| abc204c04e | |||
| 9550688c1e | |||
| 9dcd76d264 | |||
| 0409cd8b66 | |||
| 6180569b10 | |||
| f71e10ee06 | |||
| ca59546711 | |||
| 4a82595f26 | |||
| 1ada5ac334 | |||
| e18d56c838 | |||
| 7728aecb4f | |||
| e28ab5a956 | |||
| 1e395fd09e | |||
| ffee156c17 | |||
| 02e2ea37f3 | |||
| fdc9201660 | |||
| 5686ae5468 | |||
| 9960f15a1b | |||
| 397a53ed1c | |||
| 1c1bbe2551 | |||
| 68040173c6 | |||
| 4bf3fe65db | |||
| 34db5de8c3 | |||
| 0be3f833df | |||
| 4b2e8fc5ec | |||
| 487259a96d | |||
| fd62db1720 | |||
| 669ae20824 | |||
| 6de61b965e | |||
| 12b40e6071 | |||
| 498854f04d | |||
| 15cfb821d3 | |||
| 2e51f46dfd | |||
| 05cf121666 | |||
| d505388f0e | |||
| 6aeda935f1 | |||
| a5effba553 | |||
| b83a72e63f | |||
| 0d840adfca | |||
| 1f959932c1 | |||
| 10eb24b2ce | |||
| 66b85b0175 | |||
| bc42604045 | |||
| 3645216669 | |||
| 85da2e538d | |||
| e4d24a02da | |||
| bb3a316e35 | |||
| 49c35a2ea0 | |||
| ef1f9eade2 | |||
| 8bdcc17799 | |||
| f591e258f7 | |||
| f6486f9b34 | |||
| 48dbdb90e9 | |||
| 003517247f | |||
| 888f8fd16e | |||
| ea7ee88490 | |||
| d38021f061 | |||
| 507e1385a6 | |||
| 907bdaf043 | |||
| 0dd8d430b9 | |||
| fd46c51dba | |||
| ddae5719cf | |||
| 898912f8b1 | |||
| 45d12b2811 | |||
| 826e680f37 | |||
| 737fd72b73 | |||
| 3fe66d80cb | |||
| f03cb303c3 | |||
| 9ff83bd6ca | |||
| d6cc80074d | |||
| 06273ba2bc | |||
| 628c6b2f0d | |||
| 2f15fbb752 | |||
| c1aa2ebec5 | |||
| 3b8f00e3f9 | |||
| 05f73eedf9 | |||
| 9f3f346543 | |||
| 98fb61d932 | |||
| 5fec8db901 | |||
| 97dae2cd62 | |||
| 29bdacf69a | |||
| 563cbac88c | |||
| e24f9d33cc | |||
| e4bf58da19 | |||
| f1b0f0eab2 | |||
| 17684afba1 | |||
| 0e3ae78de7 | |||
| 7736bc6f58 | |||
| 390d2b472c | |||
| cc87fba0dd | |||
| 426484adf8 | |||
| 2f31680823 | |||
| 31c6c3abb2 | |||
| 887131d4af | |||
| 8f9d490063 | |||
| ede627b4ac | |||
| 4b65005823 | |||
| 8f1140abad | |||
| 337984c618 | |||
| 530316c2c3 | |||
| 6e4c1b6642 | |||
| ee4fa81376 | |||
| f184ef0afb | |||
| ad84b60ae4 | |||
| cdf7d94652 | |||
| 09792a9a05 | |||
| 75ca487be1 | |||
| e65dcb41f4 | |||
| 6a07a6b1a2 | |||
| 0f5850ef80 | |||
| a79f4bf73c | |||
| ced72fc864 | |||
| 49ddabbc36 | |||
| a026f0b349 | |||
| 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 |
@@ -0,0 +1,248 @@
|
||||
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: 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.
|
||||
# Runs before JDK/Python/SDK/NDK setup so a misconfigured release
|
||||
# tag fails in seconds instead of after several minutes of setup.
|
||||
if: ${{ steps.label.outputs.is_release == 'true' && env.ANDROID_KEYSTORE_BASE64 == '' }}
|
||||
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: 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: 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"
|
||||
@@ -34,8 +34,8 @@ jobs:
|
||||
|
||||
- name: Cross-build Windows distribution
|
||||
run: |
|
||||
chmod +x build-dist-windows.sh
|
||||
./build-dist-windows.sh "v${{ inputs.version }}"
|
||||
chmod +x build/build-dist-windows.sh
|
||||
./build/build-dist-windows.sh "v${{ inputs.version }}"
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
@@ -70,8 +70,8 @@ jobs:
|
||||
|
||||
- name: Build Linux distribution
|
||||
run: |
|
||||
chmod +x build-dist.sh
|
||||
./build-dist.sh "v${{ inputs.version }}"
|
||||
chmod +x build/build-dist.sh
|
||||
./build/build-dist.sh "v${{ inputs.version }}"
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
|
||||
+150
-29
@@ -4,10 +4,21 @@ 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 }}
|
||||
@@ -65,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.
|
||||
@@ -78,14 +90,17 @@ 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)))
|
||||
")
|
||||
|
||||
# Created as draft so the release isn't user-visible until every
|
||||
# build job has attached its assets. The publish-release job at
|
||||
# the end of the workflow flips draft=false once all builds pass.
|
||||
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -93,7 +108,7 @@ jobs:
|
||||
\"tag_name\": \"$TAG\",
|
||||
\"name\": \"LedGrab $TAG\",
|
||||
\"body\": $BODY_JSON,
|
||||
\"draft\": false,
|
||||
\"draft\": true,
|
||||
\"prerelease\": $IS_PRE
|
||||
}")
|
||||
|
||||
@@ -111,6 +126,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
|
||||
@@ -135,8 +153,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
|
||||
@@ -148,6 +166,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: |
|
||||
@@ -174,15 +194,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
|
||||
@@ -207,8 +238,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
|
||||
@@ -218,34 +249,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
|
||||
@@ -269,6 +310,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 \
|
||||
@@ -276,7 +320,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 }}"
|
||||
@@ -295,7 +342,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 }}"
|
||||
@@ -306,3 +353,77 @@ jobs:
|
||||
if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
|
||||
docker push "$REGISTRY:latest"
|
||||
fi
|
||||
|
||||
# Best-effort arm64 (Raspberry Pi / arm64 HAOS hosts). Runs AFTER the
|
||||
# amd64 push so the amd64 image always ships even if this fails.
|
||||
# Deliberately avoids `docker buildx` (its docker-container driver needs
|
||||
# nested networking the TrueNAS runners lack — see contexts/ci-cd.md):
|
||||
# instead it cross-builds a single arm64 image via QEMU binfmt and folds
|
||||
# amd64 + arm64 into multi-arch manifest lists under the existing tags.
|
||||
# `continue-on-error` keeps a runner that can't emulate arm64 from
|
||||
# failing the release; the plain amd64 tags pushed above remain valid.
|
||||
- name: Build + publish arm64 (multi-arch manifest, best-effort)
|
||||
if: github.event_name == 'push' && steps.docker-login.outcome == 'success'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -e
|
||||
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
REGISTRY="${{ steps.meta.outputs.registry }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
|
||||
# Register arm64 emulation. If the runner forbids privileged
|
||||
# containers this fails and the whole step is skipped.
|
||||
docker run --privileged --rm tonistiigi/binfmt --install arm64
|
||||
|
||||
# Cross-build the arm64 image (QEMU-emulated — slow but uses arm64
|
||||
# manylinux wheels, so no source compilation). Stays in the local
|
||||
# daemon alongside the amd64 image from the previous build step.
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--platform linux/arm64 \
|
||||
--build-arg APP_VERSION="$VERSION" \
|
||||
--label "org.opencontainers.image.version=$VERSION" \
|
||||
--label "org.opencontainers.image.revision=${{ gitea.sha }}" \
|
||||
-t "$REGISTRY:$VERSION-arm64" \
|
||||
./server
|
||||
|
||||
# Fold amd64 + arm64 into a multi-arch manifest list under each
|
||||
# user-facing tag. The arch-suffixed tags remain pullable directly.
|
||||
publish_manifest() {
|
||||
local t="$1"
|
||||
docker tag "$REGISTRY:$t" "$REGISTRY:$t-amd64"
|
||||
docker push "$REGISTRY:$t-amd64"
|
||||
docker tag "$REGISTRY:$VERSION-arm64" "$REGISTRY:$t-arm64"
|
||||
docker push "$REGISTRY:$t-arm64"
|
||||
docker manifest create --amend "$REGISTRY:$t" \
|
||||
"$REGISTRY:$t-amd64" "$REGISTRY:$t-arm64"
|
||||
docker manifest push "$REGISTRY:$t"
|
||||
}
|
||||
|
||||
publish_manifest "$TAG"
|
||||
publish_manifest "$VERSION"
|
||||
if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
|
||||
publish_manifest "latest"
|
||||
fi
|
||||
|
||||
# ── Publish the release (flip draft=false) ─────────────────
|
||||
# Runs only after every build job succeeded so users never see a
|
||||
# release that's missing artifacts or sha256 sidecars (the in-app
|
||||
# updater refuses to install without them).
|
||||
publish-release:
|
||||
needs: [create-release, build-windows, build-linux, build-docker]
|
||||
if: github.event_name == 'push' && success()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Promote draft release to published
|
||||
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 }}"
|
||||
|
||||
curl -s -X PATCH "$BASE_URL/releases/$RELEASE_ID" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"draft": false}'
|
||||
echo "Published release $RELEASE_ID"
|
||||
|
||||
@@ -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
|
||||
|
||||
+30
-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,8 @@ tmp/
|
||||
# OS
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
# Added by code-review-graph
|
||||
.code-review-graph/
|
||||
|
||||
# vex semantic-search embedding cache (auto-downloaded on first --semantic run)
|
||||
.fastembed_cache/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"code-review-graph": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"code-review-graph",
|
||||
"serve"
|
||||
],
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,10 @@ repos:
|
||||
args: [--line-length=100, --target-version=py311]
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.0
|
||||
# Bumped from v0.8.0 so the hook recognises UP045
|
||||
# (non-pep604-annotation-optional), which the v0.13+ ruff split off
|
||||
# from UP007. Pyproject.toml extend-selects both rules.
|
||||
rev: v0.15.12
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--line-length=100, --target-version=py311]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# vex configuration — https://github.com/tenatarika/vex
|
||||
#
|
||||
# Place this file in your project root as .vex.toml
|
||||
|
||||
# Glob patterns to exclude from indexing (gitignore syntax, on top of .gitignore)
|
||||
# exclude = [
|
||||
# "vendor/**",
|
||||
# "node_modules/**",
|
||||
# "*.generated.go",
|
||||
# "dist/**",
|
||||
# ]
|
||||
|
||||
# Default output format: "text", "json", or "compact"
|
||||
# format = "text"
|
||||
|
||||
# Enable semantic embeddings by default (slower indexing, enables meaning-based search)
|
||||
semantic = true
|
||||
|
||||
# Automatically run `vex update` before search if the index is stale
|
||||
auto_update = true
|
||||
|
||||
# Embedder used for semantic indexing. IDs: minilm-l6-v2 (default, CPU-fast),
|
||||
# jina-code (code-specialized, GPU-worthy), bge-base-en-v1.5, bge-large-en-v1.5.
|
||||
# Changing the embedder requires a full reindex.
|
||||
embedder = "jina-code"
|
||||
@@ -0,0 +1,428 @@
|
||||
# LedGrab Architecture Audit — Remaining Items
|
||||
|
||||
Roadmap for the architecture-audit refactor sprint that started 2026-05-22.
|
||||
This file lists every audit finding that is **not yet addressed**; the ones
|
||||
already landed in commits `563cbac..2f15fbb` are summarised below for
|
||||
context.
|
||||
|
||||
## Already done (10 commits)
|
||||
|
||||
| Commit | Findings addressed |
|
||||
|---|---|
|
||||
| `563cbac` | C2, C11, C1 (parallel-change only), C3, C4, C6, C7-streams |
|
||||
| `29bdacf` | C5 (HA/Z2M swap helper; full ABC deferred) |
|
||||
| `97dae2c` | H1 |
|
||||
| `5fec8db` | M4 |
|
||||
| `98fb61d` | H2 |
|
||||
| `9f3f346` | M5 |
|
||||
| `05f73ee` | H6 (bindable extraction only) |
|
||||
| `3b8f00e` + `c1aa2eb` | C7 store-side |
|
||||
| `2f15fbb` | H3 |
|
||||
| _uncommitted (2026-05-27 autonomous pass)_ | H6-rest, H8, M7 (foundation + 3 reference files) |
|
||||
|
||||
All commits have ≥1 code-review subagent pass with HIGH findings fixed
|
||||
before commit. Tests pass on each commit; ruff clean; tsc + bundle build
|
||||
clean for the frontend commit.
|
||||
|
||||
The two CRITICAL **data-safety** items (C2 silent CSS fallback, C11
|
||||
string-replace JSON migration) are fixed. The two CRITICAL
|
||||
**parallel-change** problems for color-strip + value-source dispatch are
|
||||
fixed. The two HIGH dispatch problems (H1 effects, H2 rules) are fixed.
|
||||
|
||||
---
|
||||
|
||||
## Remaining backend items
|
||||
|
||||
### HIGH
|
||||
|
||||
#### H4 — `Device.__init__` 40+ params mixing per-type fields
|
||||
|
||||
**File:** `server/src/ledgrab/storage/device_store.py:46-150`
|
||||
|
||||
The `Device` dataclass constructor accepts ~40 parameters that mix common
|
||||
fields with DMX-only / DDP-only / Hue-only / Yeelight-only / Wiz-only /
|
||||
LIFX-only / Govee-only / Nanoleaf-only / SPI-only / Chroma-only /
|
||||
GameSense-only fields. Setting `hue_username` on a WLED device is
|
||||
silently ignored.
|
||||
|
||||
**Approach:** introduce per-device-type config dataclasses
|
||||
(`DmxConfig`, `HueConfig`, `DdpConfig`, …) and make `Device.config` a
|
||||
discriminated union. Per-type validation moves to the config classes.
|
||||
Wire migration: every existing device row needs to be re-parsed; use the
|
||||
versioned `MigrationRunner` introduced in Phase 1.2.
|
||||
|
||||
**Risk:** medium-high. Touches:
|
||||
- `storage/device_store.py` — Device dataclass, `from_dict`, `to_dict`,
|
||||
`create_device`, `update_device`
|
||||
- `api/schemas/devices.py` — Pydantic schemas
|
||||
- `api/routes/devices.py` — request validation
|
||||
- `core/devices/*` — every provider reads device fields
|
||||
- A new migration to translate flat fields → nested `config`
|
||||
|
||||
**Estimated scope:** ~1500 LOC diff, 1-2 dedicated sessions.
|
||||
|
||||
#### H5 — `WledTargetProcessor` god class (32 methods, 5 responsibilities)
|
||||
|
||||
**File:** `server/src/ledgrab/core/processing/wled_target_processor.py` (1238 LOC)
|
||||
|
||||
Conflates:
|
||||
1. Device connectivity (probe, liveness, reconnect)
|
||||
2. FPS negotiation (adaptive_fps, keepalive_interval, state_check_interval)
|
||||
3. LED resampling (`_fit_to_device` — 60 lines of numpy)
|
||||
4. Preview WebSocket fanout (`_preview_clients`, `_broadcast_led_preview`)
|
||||
5. Metrics emission (`get_state`, `get_metrics`)
|
||||
|
||||
**Approach:** extract `WledDeviceConnector`, `WledPixelSender`,
|
||||
`TargetFitProcessor`, `TargetPreviewBroadcaster`, `TargetMetricsCollector`.
|
||||
`WledTargetProcessor` becomes an orchestrator that composes them.
|
||||
|
||||
**Risk:** HIGHEST in the audit. This class drives physical LED hardware
|
||||
in production. A regression caught at runtime (in the user's living
|
||||
room) is the expensive failure mode. Needs manual verification with at
|
||||
least one real WLED device after the refactor.
|
||||
|
||||
**Coupled with:** C5 (HA/Z2M shared the same shape; should extract a
|
||||
common `BaseTargetProcessor` ABC at the same time so all three
|
||||
processors share lifecycle / preview / metrics code).
|
||||
|
||||
**Estimated scope:** ~2000 LOC diff, 2-3 dedicated sessions, with manual
|
||||
device testing after each.
|
||||
|
||||
#### H7 — `device-discovery.ts` 1745 LOC
|
||||
|
||||
Frontend mirror of H4. The `onDeviceTypeChanged` handler has a giant
|
||||
switch with 15+ device kinds and 15+ `_showXxxFields` / `_buildXxxItems`
|
||||
helpers. Adding a device type requires editing 5 separate frontend hooks.
|
||||
|
||||
**Approach:** mirror the H4 backend redesign — once the storage layer
|
||||
has per-type config objects, the frontend can have a per-type field-set
|
||||
registry. Best done **after** H4 lands so the schemas drive the
|
||||
registry.
|
||||
|
||||
**Estimated scope:** 1-2 sessions; coupled to H4.
|
||||
|
||||
#### H8 — `automations.ts` 1410 LOC — ✅ DONE (uncommitted, 2026-05-27)
|
||||
|
||||
Frontend mirror of H2 (rule polymorphism). Already addressed on the
|
||||
backend in `98fb61d`; the frontend dispatch on `RuleType` was
|
||||
hand-rolled.
|
||||
|
||||
**Done:** the two remaining hand-rolled dispatch ladders were converted
|
||||
to registries keyed by `RuleType`, alongside the pre-existing
|
||||
`RULE_CHIP_RENDERERS`:
|
||||
- `RULE_FIELD_RENDERERS` — the `renderFields` if/elif ladder was
|
||||
extracted into module-level `_renderXxxFields(container, data)`
|
||||
functions (they only ever closed over `container`); the in-row
|
||||
`renderFields` is now a 3-line dispatcher.
|
||||
- `RULE_COLLECTORS` — the `getAutomationEditorRules` if/elif ladder
|
||||
became per-type collectors; the loop is now a registry lookup.
|
||||
- All three registries are typed `Record<RuleType, …>` (compile-time
|
||||
exhaustiveness) and an import-time `_assertRuleHandlerCoverage()`
|
||||
logs loudly if any registry drifts from `RULE_TYPE_KEYS`. (Frontend
|
||||
logs rather than throws — a thrown error at import would brick the
|
||||
whole bundle, not just the editor — the one intentional divergence
|
||||
from the backend's raising `_assert_rule_handler_coverage`.)
|
||||
|
||||
Adding a new rule type now means: one entry in `RULE_TYPE_KEYS`,
|
||||
`RULE_TYPE_ICONS`, and each of the three registries — and tsc + the
|
||||
coverage check flag any omission.
|
||||
|
||||
Verified: tsc + bundle build clean; typescript-reviewer APPROVE (the
|
||||
extracted renderer bodies are byte-identical to the originals; no stray
|
||||
closure captures; http_poll widget-stash + HA entity loading preserved).
|
||||
|
||||
### MEDIUM
|
||||
|
||||
#### M1 — `ProcessorManager.add_target` shotgun (11 args, WLED-leak)
|
||||
|
||||
**File:** `server/src/ledgrab/core/processing/processor_manager.py:396`
|
||||
|
||||
Method is named generically (`add_target`) but accepts `protocol="ddp"`
|
||||
and `keepalive_interval` — WLED-only fields. HA and Z2M have sibling
|
||||
methods with their own bespoke params.
|
||||
|
||||
**Approach:** extract a `TargetFactory` (per-kind builders, similar to
|
||||
`value_source_factories.py` from Phase 7). Couple with H5/C5 work.
|
||||
|
||||
#### M2 — `TargetContext` god-bag
|
||||
|
||||
**File:** `server/src/ledgrab/core/processing/processor_manager.py`
|
||||
|
||||
`@dataclass TargetContext` exposes ~8 attributes (device_store,
|
||||
color_strip_stream_manager, value_stream_manager, metrics_history,
|
||||
mqtt_manager, ha_manager, …). Processors silently depend on whichever
|
||||
fields they read. Tests have to construct a huge mock context.
|
||||
|
||||
**Approach:** make per-processor explicit dependency injection. Couple
|
||||
with H5 work.
|
||||
|
||||
#### M3 — Validation duplicated across layers
|
||||
|
||||
Field-level constraints (composite nesting depth, name uniqueness, span
|
||||
ranges) are enforced in route + schema + store. Adding a new constraint
|
||||
means editing 3 places.
|
||||
|
||||
**Approach:** move all validation to the model/schema layer (Pydantic
|
||||
validators + dataclass `__post_init__`). Routes trust the schema; store
|
||||
trusts the model.
|
||||
|
||||
**Risk:** moderate — cross-cutting; needs careful review of which layer
|
||||
currently owns which constraint.
|
||||
|
||||
#### M6 — `ws_stream.py` mixed concerns (699 LOC)
|
||||
|
||||
**File:** `server/src/ledgrab/api/routes/color_strip_sources/ws_stream.py`
|
||||
|
||||
The worst part (stream-creation dispatch) was fixed in Phase 2.1 — it
|
||||
now calls `color_strip_kinds.build_stream(source, deps)`. The remaining
|
||||
699 lines mix config parsing + WebSocket lifecycle + frame loop. Could
|
||||
extract the frame loop into a separate `PreviewFrameLoop` class.
|
||||
|
||||
**Estimated scope:** half a session. Low impact since the parallel-change
|
||||
problem is already fixed.
|
||||
|
||||
#### M7 — No shared frontend API client — 🟡 FOUNDATION DONE (uncommitted, 2026-05-27)
|
||||
|
||||
**File:** every `static/js/features/*.ts`
|
||||
|
||||
`fetchWithAuth(...)` + bespoke error-unwrapping is copy-pasted in every
|
||||
feature's save / load function. ~45 files, ~243 call sites.
|
||||
|
||||
**Done:** `static/js/core/api-client.ts` now provides typed
|
||||
`apiGet` / `apiPost` / `apiPut` / `apiPatch` / `apiDelete` that wrap
|
||||
`fetchWithAuth` (so auth, 401-relogin, retry, timeout, and the offline
|
||||
toast are unchanged) and collapse the repeated
|
||||
`if (!resp.ok) { detail || HTTP <status> } … resp.json()` dance into one
|
||||
call returning a typed body and throwing `ApiError` on failure. The
|
||||
`detail` unwrap is hardened to join FastAPI validation arrays instead of
|
||||
stringifying to `[object Object]`. **35 feature/core files migrated**
|
||||
(covers GET/POST/PUT/DELETE, typed response bodies, custom i18n error
|
||||
messages, silent-failure GETs, bulk `Promise.allSettled` deletes,
|
||||
inline-error saves, array-`detail` joins, fire-and-forget POSTs, and
|
||||
local catch handling) — reviewer-approved for behaviour parity across
|
||||
the riskier divergences. Migrated files include the integration sources
|
||||
(weather / HA / MQTT / HTTP), the template families (capture / audio /
|
||||
audio-processing / pattern), the scene-preset CRUD, the simple-CRUD
|
||||
entity files (sync-clocks / audio-sources / game-integration /
|
||||
gradient / displays / device-discovery), the light-target editors
|
||||
(z2m / ha), the preferences modules (dashboard-layout / card-modes /
|
||||
notifications-watcher), the calibration editors (simple + advanced),
|
||||
the entire `automations.ts` and `devices.ts` CRUD surfaces, and several
|
||||
core utilities (`api-client.ts` itself, `cache.ts`, `command-palette.ts`,
|
||||
`graph-connections.ts`, `tag-input.ts`, `process-picker.ts`,
|
||||
`perf-charts.ts`, `icon-picker.ts`, `update.ts`, `integrations.ts`).
|
||||
|
||||
Also added **14 new locale keys** (en / ru / zh) so the fallback
|
||||
messages the migration surfaces — `pattern.error.save_failed`,
|
||||
`audio_processing.error.save_failed`, `audio_template.error.save_failed`,
|
||||
`audio_template.error.load_failed`, `templates.error.save_failed`,
|
||||
`templates.error.load_failed`, `gradient.error.save_failed`,
|
||||
`target.error.load_failed`, `device.error.load_failed`,
|
||||
`automations.error.{load,save,delete,toggle}_failed`, plus
|
||||
`gradient.error.delete_failed` for ru/zh — are translated instead of
|
||||
hardcoded English. A scan confirms **no `errorMessage: '<English>'`
|
||||
strings remain** in the migrated diff.
|
||||
|
||||
**Remaining:** 9 feature files (~94 call sites). All but one are the
|
||||
big god-modules whose migration is best done as part of their C8/C9/C10
|
||||
splits: `streams.ts` (18), `settings.ts` (18), `targets.ts` (16),
|
||||
`dashboard.ts` (15), `color-strips/index.ts` (8), `graph-editor.ts` (7),
|
||||
`assets.ts` (6 — also blocked by multipart upload + blob download paths
|
||||
that legitimately bypass the JSON client), and `value-sources.ts` (5).
|
||||
The lone leaf file still on `fetchWithAuth` is `pairing-flow.ts` (1) —
|
||||
its branching on raw `Response.status` codes (200 / 409 / 4xx) doesn't
|
||||
fit the api-client contract, so it stays on raw fetch by design.
|
||||
Migration is mechanical but **not** a blind find/replace — each site
|
||||
carries its own localised error key that must be preserved as the
|
||||
`errorMessage` option, and binary/multipart endpoints (e.g.
|
||||
`assets.ts` file upload / blob download) must stay on raw
|
||||
`fetchWithAuth` (the client is JSON-only). Each migrated file ideally
|
||||
gets manual UI smoke-testing. **Behaviour note:** migrated GET sites now
|
||||
prefer the server's `detail` over the generic localised fallback when
|
||||
present — matching what the write paths already did; intended, but
|
||||
user-visible.
|
||||
|
||||
#### M8 — Global `_cached*` `let` vars
|
||||
|
||||
Mutable module-level state mutated from multiple feature modules. No
|
||||
subscription model — features manually `invalidate()` after CRUD.
|
||||
|
||||
**Approach:** introduce a reactive cache (EventEmitter pattern or a tiny
|
||||
store like Nano Stores). Couple with M7 (the API client can drive cache
|
||||
invalidation on write).
|
||||
|
||||
#### M9 — `dashboard.ts` 1421 LOC
|
||||
|
||||
Frontend god-module orchestrating + rendering device / target / CSS
|
||||
cards. Couple with C8/C9/C10 frontend split work.
|
||||
|
||||
#### M10 — Duplicate frontend modal classes
|
||||
|
||||
`ValueSourceModal`, `StreamEditorModal`, `TargetEditorModal`,
|
||||
`AddDeviceModal`, etc. each reimplement pristine-check / undo / focus
|
||||
management.
|
||||
|
||||
**Approach:** introduce a `FormModal<T>` base class.
|
||||
|
||||
#### M11 — Hardcoded `_getSectionForSource` / `_getTabForSource`
|
||||
|
||||
Routing tables duplicated across multiple feature files (streams.ts,
|
||||
value-sources.ts). Adding a new stream type requires hunting strings.
|
||||
|
||||
**Approach:** single routing registry keyed by source_type.
|
||||
|
||||
#### M12 — Late imports masking cycles
|
||||
|
||||
Partially addressed by the kind registries (Phase 2.1, 2.2). Some
|
||||
late-imports still exist in `value_stream.py`, `audio_stream.py`, the
|
||||
target processors. Resolving them requires restructuring module layout
|
||||
to break the circular dependencies.
|
||||
|
||||
**Estimated scope:** small follow-up after H5.
|
||||
|
||||
### LOW
|
||||
|
||||
#### L1 — `(src as any).field` casts in `value-sources.ts`
|
||||
|
||||
Discriminated unions aren't narrowed properly. Couple with C8 frontend
|
||||
split.
|
||||
|
||||
#### L2 — Mutable state without locks
|
||||
|
||||
`_preview_clients`, `_last_preview_data`, `_color_stream`,
|
||||
`_css_stream` are mutated from multiple async tasks without explicit
|
||||
locks. Production has not exhibited issues but the contract is fragile.
|
||||
|
||||
**Approach:** add explicit `asyncio.Lock` per processor. Couple with H5.
|
||||
|
||||
#### L3 — `Calibration.validate()` raises instead of returning result
|
||||
|
||||
**File:** `server/src/ledgrab/core/capture/calibration.py:164`
|
||||
|
||||
All 4 call sites currently rely on the raise; converting to
|
||||
`ValidationResult` would force every caller to check a return value
|
||||
without adding safety. **Recommendation:** skip — current design is
|
||||
appropriate.
|
||||
|
||||
#### L4 — `_SOURCE_TYPE_MAP` is module-private
|
||||
|
||||
No public `GET /api/v1/source-types` discovery endpoint. Frontend
|
||||
hardcodes the list of source types in `types.ts`.
|
||||
|
||||
**Approach:** add a discovery route + matching frontend fetch. Couple
|
||||
with H6 frontend split (since `types.ts` is involved).
|
||||
|
||||
#### L5 — `AudioValueStream` implicit state machine
|
||||
|
||||
**File:** `server/src/ledgrab/core/processing/value_stream.py:169-383`
|
||||
|
||||
`get_value()` can be called before `start()`; transitions are implicit.
|
||||
**Approach:** explicit State pattern. Low value (production callers
|
||||
always start before reading).
|
||||
|
||||
---
|
||||
|
||||
## Remaining frontend items (all)
|
||||
|
||||
### CRITICAL
|
||||
|
||||
- **C8** — `value-sources.ts` 1972 LOC (4 god-functions, type-dispatch ladders)
|
||||
- **C9** — `graph-editor.ts` 2707 LOC (layout + interaction + state + WS sync + …)
|
||||
- **C10** — `streams.ts` 2341 LOC (picture / audio / template kitchen-sink)
|
||||
|
||||
### Other frontend (severity in main list above)
|
||||
|
||||
- **H6 rest** — ✅ DONE (uncommitted, 2026-05-27): `types.ts` (1140 LOC)
|
||||
split into 18 per-entity files under `types/` (joining the existing
|
||||
`bindable.ts`); `types.ts` is now a ~200-line pure re-export barrel, so
|
||||
every `import { … } from '../types.ts'` still resolves. Reviewer
|
||||
confirmed all 102 exported symbols preserved, none renamed.
|
||||
- **H7** — `device-discovery.ts` 1745 LOC (couple with H4)
|
||||
- **H8** — `automations.ts` 1410 LOC (mirror H2)
|
||||
- **M7** — shared API client
|
||||
- **M8** — reactive cache
|
||||
- **M9** — `dashboard.ts` 1421 LOC
|
||||
- **M10** — `FormModal<T>` base
|
||||
- **M11** — routing registry
|
||||
- **L1** — narrowing the discriminated unions
|
||||
|
||||
The frontend remainder is **multi-day work** even when broken up by
|
||||
finding. Recommended approach: a dedicated frontend sprint with the
|
||||
typescript-reviewer agent + manual UI testing for each god-module
|
||||
split. Order:
|
||||
|
||||
1. Finish `types.ts` split (H6) — pure organisation, low risk, unblocks
|
||||
the rest
|
||||
2. Introduce API client (M7) — every feature file gains a cleaner shape
|
||||
3. Split `value-sources.ts` (C8) — uses the API client + per-type
|
||||
registry pattern
|
||||
4. Split `streams.ts` (C10)
|
||||
5. Split `graph-editor.ts` (C9) — needs the most care; the file owns
|
||||
the entire visual editor
|
||||
6. Polish: `dashboard.ts` (M9), `device-discovery.ts` (H7),
|
||||
`automations.ts` (H8), `FormModal` (M10), routing registry (M11),
|
||||
reactive cache (M8), narrowing (L1)
|
||||
|
||||
---
|
||||
|
||||
## Recommended ordering for future sessions
|
||||
|
||||
### Session A — Frontend sprint (multi-day)
|
||||
|
||||
Address H6-rest, C8, C9, C10, H7, H8, M7-M11, L1. See order above.
|
||||
Critical to have typescript-reviewer feedback + manual UI testing after
|
||||
each split.
|
||||
|
||||
> **Progress (2026-05-27, uncommitted):** steps 1 & 2 of the order above
|
||||
> are done — H6-rest (`types.ts` split) and M7-foundation (`api-client.ts`
|
||||
> + 3 reference migrations). H8 (automations registry) also landed. Still
|
||||
> open: C8, C9, C10, H7, the remaining ~40 M7 file migrations, M8-M11, L1.
|
||||
> Next per the order: introduce the API client everywhere (finish M7),
|
||||
> then split `value-sources.ts` (C8).
|
||||
|
||||
### Session B — Device redesign (1-2 sessions)
|
||||
|
||||
Address H4 alone. Touches device storage + provider classes; needs a
|
||||
data migration. Once H4 lands, H7 frontend mirror can follow.
|
||||
|
||||
### Session C — BaseTargetProcessor ABC (2-3 sessions)
|
||||
|
||||
Address C5 (full) + H5 + M1 + M2 + L2 together. Highest risk in the
|
||||
audit because it drives physical LED hardware. Each step needs manual
|
||||
verification with a real device.
|
||||
|
||||
### Session D — Polish (half a session)
|
||||
|
||||
Address M3, M6 (remainder), M12 (remainder), L3 (decision: skip), L4,
|
||||
L5.
|
||||
|
||||
---
|
||||
|
||||
## Pattern reference for new contributors
|
||||
|
||||
Three registry-pattern templates that already exist in the codebase and
|
||||
should be the model for the remaining dispatch ladders:
|
||||
|
||||
1. **Class-level handler dict + import-time coverage assertion**
|
||||
- `core/processing/effect_stream.py::_RENDERERS`
|
||||
(`@_effect_renderer` decorator + `@_collect_effect_renderers`
|
||||
class decorator)
|
||||
- `core/automations/automation_engine.py::AutomationEngine._RULE_HANDLERS`
|
||||
(module-level binding after class definition)
|
||||
- `api/routes/output_targets.py::_TARGET_RESPONSE_BUILDERS`
|
||||
(response-shape dispatch keyed by storage class)
|
||||
|
||||
2. **Per-type free functions + dependency-bag dataclass**
|
||||
- `core/processing/color_strip_kinds.py` (`StreamDeps` + `STREAM_BUILDERS`)
|
||||
- `core/processing/value_kinds.py` (`ValueStreamDeps` + `STREAM_BUILDERS`)
|
||||
- `storage/value_source_factories.py` (`CREATE_BUILDERS` + `UPDATE_APPLIERS`)
|
||||
|
||||
3. **Versioned migration runner**
|
||||
- `storage/data_migrations.py` (`MigrationRunner` + `DataMigration` ABC)
|
||||
- Used for any storage rename / field-shape change in the future.
|
||||
- Audit-table contract: atomic transaction covers
|
||||
applied-check + apply + record, so partial-failure cannot leave
|
||||
data rewritten but unrecorded.
|
||||
|
||||
Adding a new feature that touches dispatch should reach for one of
|
||||
these three patterns before writing a fresh if/elif chain.
|
||||
@@ -1,10 +1,41 @@
|
||||
# Claude Instructions for WLED Screen Controller
|
||||
# Claude Instructions for LedGrab
|
||||
|
||||
## Code Search
|
||||
|
||||
**If `ast-index` is available, use it as the PRIMARY code search tool.** It is significantly faster than grep and returns structured, accurate results. Fall back to grep/Glob only when ast-index is not installed, returns empty results, or when searching regex patterns/string literals/comments.
|
||||
**Priority order: `vex` (PRIMARY) → `ast-index` (fallback) → Grep/Glob (last resort).** This repo has a fully-featured `.vex.toml` index. Use vex first for any symbol/definition/usage/call-graph lookup. Fall back to ast-index only when vex legitimately can't help, and to Grep/Glob only for regex patterns, string literals, comments, config files, or unparsed languages.
|
||||
|
||||
**IMPORTANT for subagents:** When spawning Agent subagents (Plan, Explore, general-purpose, etc.), always instruct them to use `ast-index` via Bash for code search instead of grep/Glob. Example: include "Use `ast-index search`, `ast-index class`, `ast-index usages` etc. via Bash for code search" in the agent prompt.
|
||||
**IMPORTANT — use ALL vex indexing features.** The index is built with every capability enabled, and queries must take advantage of them. Keep them ON and exploit them:
|
||||
|
||||
| Capability | Status | Powers |
|
||||
| ---------- | ------ | ------ |
|
||||
| Semantic embeddings (`jina-code`, 768-dim) | ON | `vex search` (semantic channel), `similar`, `find_similar`, `duplicates` |
|
||||
| Call graph | ON | `vex callers`, `callees`, `paths`, `reachable`, `bundle --mode pr-impact` |
|
||||
| BM25 | ON | hybrid RRF text channel in `vex search` |
|
||||
| Pattern index | ON | `vex pattern` AST-shape matching |
|
||||
| C++ includes | ON | include-graph resolution |
|
||||
| Body tokens (incremental HNSW) | ON | fast incremental reindex |
|
||||
| History | ON | `vex history`, `vex diff <rev>` blame/evolution queries |
|
||||
|
||||
**In-session, use the `mcp__vex__*` MCP tools** (`search`, `show`, `usages`, `callers`, `callees`, `bundle`, `outline`, `implementations`, `similar`, `grep`, `status`, etc.) — MCP output is far cheaper in tokens than `Bash("vex …")`. Drop to Bash `vex` only for CLI-only features (`pattern`, `diff`, `paths`, `reachable`, `bundle`, `history`, `--strict`/`--why` flags), for subagent prompts, or for shell composition.
|
||||
|
||||
```bash
|
||||
vex search "query" --semantic # Hybrid semantic + BM25 search
|
||||
vex show <Symbol> # Definition body (prefer over Read)
|
||||
vex usages <Symbol> --strict # Reference sites (AST-precise on T1 langs)
|
||||
vex callers <Function> # Call sites (function-scoped)
|
||||
vex callees <Function> # Outgoing calls
|
||||
vex paths --from <A> --to <B> # Multi-hop call-graph path
|
||||
vex bundle --mode pr-impact --base master # Changed symbols + callers + reachable tests
|
||||
vex pattern '$X async fn returning Response' # AST-shape (metavariables)
|
||||
vex diff master # Symbol-level branch diff
|
||||
vex history <Symbol> # Commit evolution of a symbol
|
||||
```
|
||||
|
||||
**Maintenance:** the index has `auto_update = true`, so it refreshes on stale queries. After a `vex self-update`, rerun `vex index --history --semantic --embedder jina-code --device cuda` so newly-added extractors populate and all features stay enabled. Verify with `vex status` — every capability line should read `yes`.
|
||||
|
||||
**IMPORTANT for subagents:** Subagents don't inherit MCP. When spawning Agent subagents (Plan, Explore, general-purpose, etc.), instruct them to use `vex` via Bash for code search (e.g. include "Use `vex search`, `vex show`, `vex usages`, `vex callers` via Bash for code search; ast-index is the fallback"). Don't tell them to default to grep/Glob.
|
||||
|
||||
**Fallback — `ast-index`** (use only when vex is unavailable):
|
||||
|
||||
```bash
|
||||
ast-index search "Query" # Universal search
|
||||
@@ -28,8 +59,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 |
|
||||
@@ -42,10 +86,6 @@ ast-index changed --base master # Show symbols changed in current bran
|
||||
| [Gitea Python CI/CD Guide](https://git.dolgolyov-family.by/alexei.dolgolyov/claude-code-facts/src/branch/main/gitea-python-ci-cd.md) | Reusable CI/CD patterns: Gitea Actions, cross-build, NSIS, Docker |
|
||||
| [server/CLAUDE.md](server/CLAUDE.md) | Backend architecture, API patterns, common tasks |
|
||||
|
||||
## Task Tracking via TODO.md
|
||||
|
||||
Use `TODO.md` in the project root as the primary task tracker. **Do NOT use the TodoWrite tool** — all progress tracking goes through `TODO.md`.
|
||||
|
||||
## Documentation Lookup
|
||||
|
||||
**Use context7 MCP tools for library/framework documentation lookups** (FastAPI, OpenCV, Pydantic, yt-dlp, etc.) instead of relying on potentially outdated training data.
|
||||
@@ -91,3 +131,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
-79
@@ -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:**
|
||||
|
||||
@@ -131,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.
|
||||
@@ -160,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:
|
||||
@@ -184,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
|
||||
@@ -193,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.
|
||||
@@ -252,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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -276,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`.
|
||||
@@ -288,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
|
||||
@@ -297,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.
|
||||
@@ -324,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)
|
||||
|
||||
@@ -1,36 +1,58 @@
|
||||
# LED Grab
|
||||
|
||||
Ambient lighting system that captures screen content and drives LED strips in real time. Supports WLED, Adalight, AmbileD, and DDP devices with audio-reactive effects, pattern generation, and automated profile switching.
|
||||
Ambient lighting system that captures screen content and drives LED strips and smart lights in real time. Supports a wide range of devices — WLED, DDP, Adalight, smart bulbs, PC peripherals, Bluetooth strips, and more — with audio-reactive effects, pattern generation, and condition-based automation.
|
||||
|
||||
**Free and open source.** LedGrab is released under the [MIT license](LICENSE) — free to use, modify, and self-host, with no accounts, telemetry, or cloud dependency. Everything runs locally on your own machine and network.
|
||||
|
||||
## What It Does
|
||||
|
||||
The server captures pixels from a screen (or Android device via ADB), extracts border colors, applies post-processing filters, and streams the result to LED strips at up to 60 fps. A built-in web dashboard provides device management, calibration, live LED preview, and real-time metrics — no external UI required.
|
||||
The server captures pixels from a screen (or from a connected Android phone via ADB), extracts border colors, applies a post-processing filter pipeline, and streams the result to your LED devices at up to 60 fps. A built-in web dashboard provides device management, calibration, a visual wiring editor, live LED preview, and real-time metrics — no external UI required.
|
||||
|
||||
A Home Assistant integration exposes devices as entities for smart home automation.
|
||||
A separate Home Assistant integration exposes devices as entities for smart-home automation.
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||
|
||||
*Dashboard — live system performance, integrations, automations, and scene presets at a glance.*
|
||||
|
||||

|
||||
|
||||
*Channels — start, stop, and monitor each source-to-device pipeline with live FPS.*
|
||||
|
||||

|
||||
|
||||
*Live preview — inspect the processed capture output in real time before it reaches the LEDs.*
|
||||
|
||||
## Features
|
||||
|
||||
### Screen Capture
|
||||
|
||||
- Multi-monitor support with per-target display selection
|
||||
- 6 capture engine backends — MSS (cross-platform), DXCam, BetterCam, Windows Graphics Capture (Windows), Scrcpy (Android via ADB), Camera/Webcam (OpenCV)
|
||||
- Capture engine backends: MSS (cross-platform), DXCam, BetterCam, Windows Graphics Capture (Windows only), and Camera/Webcam (OpenCV)
|
||||
- Capture from a connected Android phone's screen via scrcpy (ADB) — the device is a *source*; LedGrab itself runs on your desktop
|
||||
- Configurable capture regions, FPS, and border width
|
||||
- Capture templates for reusable configurations
|
||||
- Reusable capture templates
|
||||
|
||||
### LED Device Support
|
||||
|
||||
- WLED (HTTP/UDP) with mDNS auto-discovery
|
||||
- Adalight (serial) — Arduino-compatible LED controllers
|
||||
- AmbileD (serial)
|
||||
- DDP (Distributed Display Protocol, UDP)
|
||||
- OpenRGB — PC peripherals (keyboard, mouse, RAM, fans, LED strips)
|
||||
- Serial port auto-detection and baud rate configuration
|
||||
LedGrab speaks many protocols, so a single setup can drive everything from a DIY strip to off-the-shelf smart bulbs:
|
||||
|
||||

|
||||
|
||||
- **Network LED controllers** — WLED (HTTP/UDP, with mDNS auto-discovery), DDP (Pixelblaze, ESPixelStick, Falcon), Open Pixel Control (OPC), Art-Net / sACN (E1.31), ESP-NOW, and generic WebSocket streaming
|
||||
- **Serial / direct hardware** — Adalight (Arduino-compatible), AmbiLED, SPI-attached strips (e.g. WS2812B), and USB HID controllers
|
||||
- **Smart bulbs & panels** — Philips Hue (Entertainment API), Nanoleaf, Yeelight, WiZ, LIFX, and Govee (Wi-Fi LAN)
|
||||
- **Bluetooth LE strips** — SP110E, Triones / HappyLighting, Zengge, and Govee BLE
|
||||
- **PC peripherals** — OpenRGB, Razer Chroma, and SteelSeries GameSense (keyboards, mice, RAM, fans, etc.)
|
||||
- **Device groups** — combine multiple devices into one logical target
|
||||
- Serial port auto-detection and baud-rate configuration
|
||||
|
||||
### Color Processing
|
||||
|
||||
- Post-processing filter pipeline: brightness, gamma, saturation, color correction, auto-crop, frame interpolation, pixelation, flip
|
||||
- Post-processing filter pipeline: brightness, gamma, saturation, color correction, auto-crop, frame interpolation, pixelation, flip, and more
|
||||
- Reusable post-processing templates
|
||||
- Color strip sources: audio-reactive, pattern generator, composite layering, audio-to-color mapping
|
||||
- Color strip sources: audio-reactive, pattern generator, gradients, composite layering, and audio-to-color mapping
|
||||
- Pattern templates with customizable effects
|
||||
|
||||
### Audio Integration
|
||||
@@ -38,17 +60,20 @@ A Home Assistant integration exposes devices as entities for smart home automati
|
||||
- Multichannel audio capture from any system device (input or loopback)
|
||||
- WASAPI engine on Windows, Sounddevice (PortAudio) engine on Linux/macOS
|
||||
- Per-channel mono extraction
|
||||
- Audio-reactive color strip sources driven by frequency analysis
|
||||
- Audio filter / processing pipeline feeding audio-reactive color sources driven by frequency analysis
|
||||
|
||||
### Automation
|
||||
|
||||
- Profile engine with condition-based switching (time of day, active window, etc.)
|
||||
- Dynamic brightness value sources (schedule-based, scene-aware)
|
||||
- Key Colors (KC) targets with live WebSocket color streaming
|
||||
- Automations engine with condition-based rules — switch targets, scenes, or brightness by time of day, active window/process, MQTT, webhooks, or game events
|
||||
- Scene presets for one-click lighting changes
|
||||
- Dynamic value sources for brightness and other parameters (schedule-based, weather-based, scene-aware)
|
||||
- Weather sources, clock sync, webhooks, and inbound/outbound HTTP endpoints
|
||||
- Game integration adapters (e.g. League of Legends)
|
||||
|
||||
### Dashboard
|
||||
|
||||
- Web UI at `http://localhost:8080` — no installation needed on the client side
|
||||
- Web UI at `http://localhost:8080` — nothing to install on the client side
|
||||
- Visual node-graph editor for wiring sources → processing → targets
|
||||
- Progressive Web App (PWA) — installable on phones and tablets with offline caching
|
||||
- Responsive mobile layout with bottom tab navigation
|
||||
- Device management with auto-discovery wizard
|
||||
@@ -57,38 +82,76 @@ A Home Assistant integration exposes devices as entities for smart home automati
|
||||
- Real-time FPS, latency, and uptime charts
|
||||
- Localized in English, Russian, and Chinese
|
||||
|
||||
### Activity Log
|
||||
|
||||
The **Activity** tab is a persistent, queryable audit log of everything LedGrab has done — entity changes, auth events, device connections, and system actions.
|
||||
|
||||
- Filter by category (auth, device, entity, capture, system), severity, actor, entity type, date range, or free text
|
||||
- Live-append of new events as they happen
|
||||
- Export as CSV or JSON (authentication required)
|
||||
- Entity crosslinks navigate directly to the relevant card
|
||||
- **Retention settings** (Settings → Activity Log): configure max age, max entry count, and toggle recording on/off
|
||||
- **Clear log** (Settings → Activity Log, requires authentication) — audited: a system entry records who cleared the log and when
|
||||
|
||||
> **Note:** The Activity Log is distinct from the **debug Log Viewer** (Settings → General → Open Log Viewer). The Log Viewer is an ephemeral real-time tail of the server's Python log stream (WARNING/ERROR lines, resets on disconnect). The Activity Log is a structured, persistent SQLite-backed record of semantic application events.
|
||||
|
||||
### Home Assistant Integration
|
||||
|
||||
- HACS-compatible custom component
|
||||
- HACS-compatible custom component (separate repository)
|
||||
- Light, switch, sensor, and number entities per device
|
||||
- Real-time metrics via data coordinator
|
||||
- Real-time metrics via a data coordinator
|
||||
- WebSocket-based live LED preview in HA
|
||||
|
||||
## Platforms
|
||||
|
||||
LedGrab runs as a desktop / server application:
|
||||
|
||||
| Platform | Status | Notes |
|
||||
| -------- | ------ | ----- |
|
||||
| Windows | ✅ Supported | Installer (`.exe`) and portable ZIP; all capture/audio backends |
|
||||
| Linux | ✅ Supported | Tarball and Docker image; X11 capture (Wayland in-container capture not supported) |
|
||||
| macOS | ✅ Supported | Runs from source / Docker; MSS capture |
|
||||
| Docker | ✅ Supported | Multi-arch container image |
|
||||
| Android (TV) | ⚠️ Experimental | An on-device Android-TV build exists (APK attached to releases) but is emulator-verified only and **not officially supported** |
|
||||
|
||||
> **There is no production Android app.** Android phones are only supported as a *capture source* (via scrcpy/ADB) from a desktop host. The on-device Android-TV build is experimental.
|
||||
|
||||
### Feature support by OS
|
||||
|
||||
| Feature | Windows | Linux / macOS | Android TV (experimental) |
|
||||
| ------- | ------- | ------------- | ------------------------- |
|
||||
| Screen capture | DXCam, BetterCam, WGC, MSS | MSS | MediaProjection; root `screenrecord` (rooted devices) |
|
||||
| Webcam capture | OpenCV (DirectShow) | OpenCV (V4L2) | Camera2 (on-demand, while capture is running) |
|
||||
| Audio capture | WASAPI, Sounddevice | Sounddevice (PulseAudio/PipeWire) | AudioPlaybackCapture (API 29+) |
|
||||
| GPU monitoring | NVIDIA (nvidia-ml-py) | NVIDIA (nvidia-ml-py) | — (CPU/RAM/battery/thermal via `/proc`) |
|
||||
| Capture from Android phone | scrcpy (ADB) | scrcpy (ADB) | — (captures its own screen instead) |
|
||||
| Notification capture | WinRT | dbus (Linux) | NotificationListenerService |
|
||||
| Monitor names | Friendly names (WMI) | Generic ("Display 0") | Single built-in display |
|
||||
| LED transports | Network, USB-serial, BLE | Network, USB-serial, BLE | Network, USB-serial (Android driver), BLE (Android bridge) |
|
||||
| Automation: window/process conditions | Supported | Partial | Foreground-app condition (UsageStatsManager) |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.11+ (or Docker)
|
||||
- A supported LED device on the local network or connected via USB
|
||||
- Windows, Linux, or macOS — all core features work cross-platform
|
||||
|
||||
### Platform Notes
|
||||
|
||||
| Feature | Windows | Linux / macOS |
|
||||
| ------- | ------- | ------------- |
|
||||
| Screen capture | DXCam, BetterCam, WGC, MSS | MSS |
|
||||
| Webcam capture | OpenCV (DirectShow) | OpenCV (V4L2) |
|
||||
| Audio capture | WASAPI, Sounddevice | Sounddevice (PulseAudio/PipeWire) |
|
||||
| GPU monitoring | NVIDIA (pynvml) | NVIDIA (pynvml) |
|
||||
| Android capture | Scrcpy (ADB) | Scrcpy (ADB) |
|
||||
| Monitor names | Friendly names (WMI) | Generic ("Display 0") |
|
||||
| Profile conditions | Process/window detection | Not yet implemented |
|
||||
- A supported LED device on the local network, connected via USB/serial, or reachable over Bluetooth
|
||||
- Windows, Linux, or macOS
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker (recommended)
|
||||
### Prebuilt downloads
|
||||
|
||||
Grab a ready-to-run build from the [Releases page](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/releases):
|
||||
|
||||
- **Windows** — `LedGrab-<version>-setup.exe` (installer, no admin required) or `LedGrab-<version>-win-x64.zip` (portable)
|
||||
- **Linux** — `LedGrab-<version>-linux-x64.tar.gz`
|
||||
- **Docker** — see below
|
||||
- **Android TV** — `.apk` (experimental, see [Platforms](#platforms))
|
||||
|
||||
### Docker (recommended for servers)
|
||||
|
||||
```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 +160,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,117 +175,76 @@ 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.
|
||||
Open <http://localhost:8080> to access the dashboard.
|
||||
|
||||
> **Important:** The default API key is `development-key-change-in-production`. Change it before exposing the server outside localhost. See [INSTALLATION.md](INSTALLATION.md) for details.
|
||||
> **Network access:** By default, LedGrab allows anonymous access only from `localhost`. Any request from another machine on your LAN is rejected unless you configure an API key (`auth.api_keys`). Set a key before exposing the server on your network — see [INSTALLATION.md](INSTALLATION.md).
|
||||
|
||||
See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including configuration, Docker manual builds, and Home Assistant setup.
|
||||
See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including configuration, Docker manual builds, and CORS 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 `WLED_DEMO` environment variable to `true`, `1`, or `yes`:
|
||||
Set the `LEDGRAB_DEMO` environment variable to `true`, `1`, or `yes`:
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
docker compose run -e WLED_DEMO=true server
|
||||
docker compose run -e LEDGRAB_DEMO=true server
|
||||
|
||||
# Python
|
||||
WLED_DEMO=true uvicorn wled_controller.main:app --host 0.0.0.0 --port 8081
|
||||
|
||||
# Windows (installed app)
|
||||
set WLED_DEMO=true
|
||||
LedGrab.bat
|
||||
LEDGRAB_DEMO=true uvicorn ledgrab.main:app --host 0.0.0.0 --port 8081
|
||||
```
|
||||
|
||||
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/
|
||||
├── server/ # Python FastAPI backend
|
||||
│ ├── src/wled_controller/
|
||||
│ │ ├── main.py # Application entry point
|
||||
│ │ ├── config.py # YAML + env var configuration
|
||||
│ │ ├── api/
|
||||
│ │ │ ├── routes/ # REST + WebSocket endpoints
|
||||
│ │ │ └── schemas/ # Pydantic request/response models
|
||||
│ │ ├── core/
|
||||
│ │ │ ├── capture/ # Screen capture, calibration, pixel processing
|
||||
│ │ │ ├── capture_engines/ # MSS, DXCam, BetterCam, WGC, Scrcpy, Camera backends
|
||||
│ │ │ ├── devices/ # WLED, Adalight, AmbileD, DDP, OpenRGB clients
|
||||
│ │ │ ├── audio/ # Audio capture engines
|
||||
│ │ │ ├── filters/ # Post-processing filter pipeline
|
||||
│ │ │ ├── processing/ # Stream orchestration and target processors
|
||||
│ │ │ └── profiles/ # Condition-based profile automation
|
||||
│ │ ├── storage/ # JSON-based persistence layer
|
||||
│ │ ├── static/ # Web dashboard (vanilla JS, CSS, HTML)
|
||||
│ │ │ ├── js/core/ # API client, state, i18n, modals, events
|
||||
│ │ │ ├── js/features/ # Feature modules (devices, streams, targets, etc.)
|
||||
│ │ │ ├── css/ # Stylesheets
|
||||
│ │ │ └── locales/ # en.json, ru.json, zh.json
|
||||
│ │ └── utils/ # Logging, monitor detection
|
||||
│ ├── config/ # default_config.yaml
|
||||
│ ├── 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
|
||||
├── INSTALLATION.md
|
||||
└── LICENSE # MIT
|
||||
```
|
||||
Demo mode uses port **8081**, config file `config/demo_config.yaml`, and stores data under `data/demo/` (separate from production data). It can run alongside the main server.
|
||||
|
||||
## 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:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
log_level: "INFO"
|
||||
cors_origins:
|
||||
- "http://localhost:8080"
|
||||
|
||||
auth:
|
||||
api_keys:
|
||||
dev: "development-key-change-in-production"
|
||||
|
||||
storage:
|
||||
devices_file: "data/devices.json"
|
||||
templates_file: "data/capture_templates.json"
|
||||
# Empty (default) → loopback-only anonymous access; LAN requests are rejected.
|
||||
# Add a key to enable LAN/remote access (generate one with: openssl rand -hex 32).
|
||||
api_keys: {}
|
||||
# api_keys:
|
||||
# dev: "your-secret-key-here"
|
||||
|
||||
logging:
|
||||
format: "json"
|
||||
file: "logs/wled_controller.log"
|
||||
file: "logs/ledgrab.log"
|
||||
max_size_mb: 100
|
||||
```
|
||||
|
||||
Environment variable override example: `WLED_SERVER__PORT=9090`.
|
||||
- Application data is stored in a SQLite database (`data/ledgrab.db` by default). Set `LEDGRAB_DATA_DIR` to relocate the data root (database + assets).
|
||||
- Environment variable override example: `LEDGRAB_SERVER__PORT=9090`.
|
||||
|
||||
See [INSTALLATION.md](INSTALLATION.md) and [`server/.env.example`](server/.env.example) for the full configuration reference.
|
||||
|
||||
## API
|
||||
|
||||
The server exposes a REST API (with Swagger docs at `/docs`) covering:
|
||||
The server exposes a REST API (with interactive Swagger docs at `/docs`) plus WebSocket endpoints. Resources include:
|
||||
|
||||
- **Devices** — CRUD, discovery, validation, state, metrics
|
||||
- **Capture Templates** — Screen capture configurations
|
||||
- **Picture Sources** — Screen capture stream definitions
|
||||
- **Picture Targets** — LED target management, start/stop processing
|
||||
- **Post-Processing Templates** — Filter pipeline configurations
|
||||
- **Color Strip Sources** — Audio, pattern, composite, mapped sources
|
||||
- **Audio Sources** — Multichannel and mono audio device configuration
|
||||
- **Pattern Templates** — Effect pattern definitions
|
||||
- **Value Sources** — Dynamic brightness/value providers
|
||||
- **Key Colors Targets** — KC targets with WebSocket live color stream
|
||||
- **Profiles** — Condition-based automation profiles
|
||||
- **Capture Templates** & **Picture Sources** — screen capture configuration and stream definitions
|
||||
- **Output Targets** — LED target management, start/stop processing, live color stream
|
||||
- **Post-Processing Templates** — filter pipeline configurations
|
||||
- **Color Strip Sources**, **Pattern Templates**, **Gradients** — color generation
|
||||
- **Audio Sources / Templates / Filters** — audio capture and reactive processing
|
||||
- **Value Sources**, **Weather Sources**, **Scene Presets** — dynamic parameters and presets
|
||||
- **Automations**, **Webhooks**, **HTTP Endpoints**, **Game Integration** — triggers and rules
|
||||
- **MQTT** & **Home Assistant** — broker sources and HA integration
|
||||
|
||||
All endpoints require API key authentication via `X-API-Key` header or `?token=` query parameter.
|
||||
Authentication uses a Bearer token (`Authorization: Bearer <api-key>`) when API keys are configured; loopback requests are anonymous by default. WebSocket connections authenticate via a first-message handshake.
|
||||
|
||||
See [docs/API.md](docs/API.md) for the full reference.
|
||||
|
||||
@@ -234,9 +256,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
|
||||
|
||||
@@ -257,16 +277,16 @@ ruff check src/ tests/
|
||||
Optional extras:
|
||||
|
||||
```bash
|
||||
pip install -e ".[perf]" # High-performance capture engines (Windows)
|
||||
pip install -e ".[camera]" # Webcam capture via OpenCV
|
||||
pip install -e ".[perf]" # High-performance capture engines (Windows: DXCam, BetterCam, WGC)
|
||||
pip install -e ".[notifications]" # OS notification capture (WinRT / dbus)
|
||||
pip install -e ".[scrcpy]" # Capture from an Android phone via scrcpy
|
||||
pip install -e ".[ble]" # Bluetooth LE LED controllers (desktop only)
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. LedGrab is MIT-licensed, so you're free to fork, modify, and self-host. Please open an issue or pull request on the [repository](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [WLED](https://github.com/Aircoookie/WLED) — LED control firmware
|
||||
- [FastAPI](https://fastapi.tiangolo.com/) — Python web framework
|
||||
- [MSS](https://python-mss.readthedocs.io/) — Cross-platform screen capture
|
||||
MIT — see [LICENSE](LICENSE). Free and open source.
|
||||
|
||||
+73
-144
@@ -1,171 +1,100 @@
|
||||
## v0.3.0 (2026-04-08)
|
||||
## v0.8.2 (2026-06-08)
|
||||
|
||||
This release brings a major expansion of integrations and source types: Home Assistant (with light output targets), a unified Integrations tab, processed audio sources with 11 DSP filters, multi-instance MQTT, a game integration system, BindableFloat for universal value-source binding, and many new value source types. Plus a much-improved build and launcher on Windows.
|
||||
### User-facing changes
|
||||
|
||||
### Features
|
||||
#### Features
|
||||
|
||||
#### Home Assistant Integration
|
||||
- Home Assistant integration with WebSocket connection, automation conditions, and UI ([2153dde](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/2153dde))
|
||||
- HA light output targets — cast LED colors to Home Assistant lights ([cb9289f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/cb9289f))
|
||||
- Entity picker for HA light mapping — searchable EntitySelect for light entities ([324a308](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/324a308))
|
||||
- HA light target live color preview — per-entity swatches via WebSocket ([40751fe](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/40751fe))
|
||||
- HA source cards use health-dot indicators ([e7c9a56](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e7c9a56))
|
||||
##### WLED native realtime UDP output
|
||||
- New realtime UDP sink speaking WLED's **DRGB / DRGBW / DNRGB** protocols, with automatic revert to the device's prior state when streaming stops ([7728aec](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7728aec))
|
||||
|
||||
#### Integrations & Tabs
|
||||
- New **Integrations** tab and responsive icon-only tabs ([b7da4ab](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b7da4ab))
|
||||
- Multi-instance MQTT — refactored from global config to entity model ([c59107c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/c59107c))
|
||||
- Game integration system ([492bdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/492bdb9))
|
||||
##### Automatic brightness limiting (ABL) / power budget
|
||||
- Per-LED power budgeting that caps total draw by scaling brightness to a configurable current/PSU limit, preventing brownouts on long strips ([ffee156](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ffee156))
|
||||
|
||||
#### Audio
|
||||
- Processed audio sources — audio filter framework ([86a9d34](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/86a9d34))
|
||||
- 11 audio filters implemented ([eb94066](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/eb94066))
|
||||
- Processed audio source model + runtime filter integration ([353c090](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/353c090), [ab43578](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ab43578))
|
||||
- Frontend audio processing templates + source type cleanup ([5534639](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/5534639), [1ce0dc6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/1ce0dc6))
|
||||
- Music sync viz modes and `auto_gain` audio filter ([b04978a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b04978a))
|
||||
##### Scene playlists
|
||||
- Scenes can be grouped into **playlists with timed auto-cycling**, so a target can rotate through looks on a schedule ([f71e10e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f71e10e))
|
||||
- Playlist + cycling state is included in the aggregated `/snapshot` response ([abc204c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/abc204c))
|
||||
|
||||
#### Value Sources
|
||||
- **BindableFloat** — universal value source binding for all scalar properties ([8a17bb5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/8a17bb5))
|
||||
- New value source types: HA entity, gradient map, strip extract ([384362c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/384362c))
|
||||
- `system_metrics` value source type ([b6713be](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b6713be))
|
||||
- Color value source test visualization ([f6c25cd](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f6c25cd))
|
||||
- HA value source test — raw value axis + behavior IconSelect ([0a87371](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/0a87371))
|
||||
- Value source card crosslinks + gradient_map test shows input value ([4b7a8d7](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/4b7a8d7))
|
||||
##### Auto edge-calibration + guided first-run setup wizard
|
||||
- Backend core for **automatic screen-edge calibration** ([0409cd8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0409cd8)), a one-call setup scaffold with an onboarding flag ([9dcd76d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9dcd76d)), and a browser-driven calibration UI ([9550688](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9550688))
|
||||
- A **guided first-run setup wizard** ties it together for new installs ([81b1808](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/81b1808)), with all-provider source discovery and a spatial corner picker ([dd43f38](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd43f38))
|
||||
|
||||
#### Sources & Assets
|
||||
- Asset-based image/video sources, notification sounds, UI improvements ([e2e1107](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e2e1107))
|
||||
- `math_wave` color strip source type ([ace2471](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ace2471))
|
||||
- `api_input` LED interpolation; fixes for LED preview, FPS charts, dashboard layout ([3e0bf85](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3e0bf85))
|
||||
- Custom file drop zone for asset upload modal ([f61a020](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f61a020))
|
||||
##### Region-of-interest (ROI) screen capture
|
||||
- Screen sampling can now be cropped to a **region of interest** instead of the whole display ([ca59546](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ca59546))
|
||||
|
||||
#### UI & UX
|
||||
- Card glare effect on dashboard and perf chart cards ([ce53ca6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ce53ca6))
|
||||
- System theme option + toast timer overlap fix ([db5008a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/db5008a))
|
||||
- Donation banner, About tab, settings UI improvements ([f3d07fc](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f3d07fc))
|
||||
- Custom app icon for shortcuts and installer ([5f70302](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/5f70302))
|
||||
##### Built-in "look" presets
|
||||
- One-click looks: **Cinematic / Vivid / Cozy / Soft / Cool** ([e18d56c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e18d56c))
|
||||
|
||||
#### Runtime
|
||||
- Port busy check before starting the server ([ea812bb](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ea812bb))
|
||||
##### Weekday + timezone scheduling
|
||||
- The time-of-day automation rule now supports **weekday selection and explicit timezones** ([1ada5ac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ada5ac))
|
||||
|
||||
### Bug Fixes
|
||||
##### Value sources
|
||||
- New **sandboxed-Jinja template combinator** for composing value sources ([6de61b9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6de61b9)) and optional normalization for magnitude sources ([669ae20](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/669ae20))
|
||||
|
||||
- Tray: replace tkinter messagebox with Win32 `MessageBoxW` ([d037a2e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d037a2e))
|
||||
- Launcher: `start-hidden.vbs` must be ASCII + CRLF, use `python.exe` ([fc8ee34](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fc8ee34))
|
||||
- Launcher: set `PYTHONPATH` and `WLED_CONFIG_PATH` in `start-hidden.vbs` ([e262a8b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e262a8b))
|
||||
- Weather CSS card shows empty source name after hard refresh ([6e8b159](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/6e8b159))
|
||||
- Replace HA test icon with refresh; make automation rules collapsible ([edc6d27](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/edc6d27))
|
||||
- `pystray` is a core dependency on Windows (no longer optional extra) ([99460a8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/99460a8))
|
||||
- Audio tree structure, filter i18n, IconSelect for filter options ([af2c89c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/af2c89c))
|
||||
- Reference check before deleting audio processing template ([d04192f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d04192f))
|
||||
- Device card header layout — URL badge overflow and hide button gap ([11d5d6b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/11d5d6b))
|
||||
- KC color strip test preview uses `LiveStreamManager` instead of raw engine ([a9e6e8c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a9e6e8c))
|
||||
- Composite layer opacity/brightness widgets + CSS layout ([78ce6c8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/78ce6c8))
|
||||
- HA light target — brightness source, `transition=0`, dashboard type label ([381ee75](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/381ee75))
|
||||
- Rename HA Lights → Home Assistant, HA Light Targets → Light Targets ([89d1b13](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/89d1b13))
|
||||
- Command palette actions and automation condition button ([c0853ce](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/c0853ce))
|
||||
- Show template name instead of ID in filter list and card badges ([be4c98b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/be4c98b))
|
||||
- Clip graph node title and subtitle to prevent overflow ([dca2d21](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/dca2d21))
|
||||
- Replace emoji with SVG icons on weather and daylight cards ([53986f8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/53986f8))
|
||||
- Send `gradient_id` instead of palette in effect transient preview ([a4a9f6f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a4a9f6f))
|
||||
##### Visual graph editor
|
||||
- The editor is now a **full wiring control surface** ([2e51f46](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2e51f46)), and you can **duplicate a selected subgraph** server-side ([15cfb82](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/15cfb82))
|
||||
|
||||
##### Android on-device capture
|
||||
- **System audio playback capture** ([fd62db1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd62db1)), **OS notification capture** via NotificationListenerService ([0be3f83](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0be3f83)), **webcam capture** via Camera2 ([4bf3fe6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4bf3fe6)), and a **foreground-app automation condition** ([1c1bbe2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c1bbe2))
|
||||
|
||||
#### Bug Fixes
|
||||
- **Security:** removed an active **weak default API key** from the shipped config — fresh installs no longer ship with a guessable key. Set your own key on first run ([5686ae5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5686ae5))
|
||||
- Removed a broken legacy `/system/mqtt/settings` route ([fdc9201](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdc9201))
|
||||
- Scene brightness value-source changes now sync to the live processor immediately ([02e2ea3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/02e2ea3))
|
||||
- Wizard hardening: scaffolded targets are registered with the ProcessorManager and the final review step is more robust ([6cd5e05](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6cd5e05))
|
||||
- Installer opens the WebUI only once after "Launch LedGrab" ([05cf121](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05cf121))
|
||||
|
||||
---
|
||||
|
||||
### Development / Internal
|
||||
|
||||
#### Build
|
||||
- Drop `packaging` dependency, inline version parsing ([d4ffe2e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d4ffe2e))
|
||||
- Fix shell syntax error in `smoke_test_imports` heredoc ([feb91ad](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/feb91ad))
|
||||
- Keep `.py` sources; smoke test skips uninstalled modules ([17c5c02](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/17c5c02))
|
||||
- Stop stripping `zeroconf/_services`; add import smoke test ([fd6776a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fd6776a))
|
||||
- Stop stripping `numpy.lib`/`linalg` from site-packages ([9f34ffb](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/9f34ffb))
|
||||
- Normalize non-PEP440 versions, fix `.py`/`compileall` ordering, wipe NSIS payload dirs ([b5842e6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b5842e6))
|
||||
#### Backend / Storage
|
||||
- `clone()` is now gated behind an **opt-in allowlist**, with expanded duplicate-handling tests ([498854f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/498854f))
|
||||
|
||||
#### CI
|
||||
- Add manual build workflow for testing artifacts ([fb98e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fb98e6e))
|
||||
- Use sparse checkout for release notes in release workflow ([9fcfdb8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/9fcfdb8))
|
||||
#### Frontend
|
||||
- In-progress dashboard customization groundwork ([6180569](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6180569))
|
||||
|
||||
#### Refactoring
|
||||
- Split `color-strips.ts` into focused modules under `color-strips/` folder ([7a9c368](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/7a9c368))
|
||||
- Key colors targets → CSS source type; HA target improvements ([3e6760f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3e6760f))
|
||||
- Move Weather and Home Assistant sources to Integrations tree group ([3c2efd5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3c2efd5))
|
||||
#### Docs
|
||||
- Actualized README + API reference with embedded screenshots ([12b40e6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/12b40e6)), graph-editor wiring-control roadmap ([d505388](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d505388)), Android audio-capture design notes ([4b2e8fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b2e8fc)); removed stale ANDROID-REVIEW planning docs ([9960f15](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9960f15))
|
||||
|
||||
#### Tests
|
||||
- Isolate tests from production database ([992495e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/992495e))
|
||||
- Processed audio sources: phase 7 testing and polish + phase 8 design review ([ce1f484](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ce1f484), [6b0e4e5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/6b0e4e5))
|
||||
|
||||
#### Chores
|
||||
- Remove processed-audio-sources plan files ([89990f8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/89990f8))
|
||||
- Remove python3.11 version pin from pre-commit config ([f345687](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f345687))
|
||||
- Large new suites for calibration solver/session (incl. adversarial), setup & scene-playlist routes, playlist engine, and ROI capture. Full suite: **2149 passing, 2 skipped**
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>All Commits (63)</summary>
|
||||
<summary>All Commits (31)</summary>
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| [d037a2e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d037a2e) | fix(tray): replace tkinter messagebox with Win32 MessageBoxW |
|
||||
| [fc8ee34](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fc8ee34) | fix(launcher): start-hidden.vbs must be ASCII + CRLF, use python.exe |
|
||||
| [e262a8b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e262a8b) | fix(launcher): set PYTHONPATH and WLED_CONFIG_PATH in start-hidden.vbs |
|
||||
| [d4ffe2e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d4ffe2e) | refactor: drop packaging dependency, inline version parsing |
|
||||
| [feb91ad](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/feb91ad) | fix(build): fix shell syntax error in smoke_test_imports heredoc |
|
||||
| [17c5c02](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/17c5c02) | fix(build): keep .py sources + make smoke test skip uninstalled modules |
|
||||
| [fd6776a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fd6776a) | fix(build): stop stripping zeroconf/_services + add import smoke test |
|
||||
| [9f34ffb](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/9f34ffb) | fix(build): stop stripping numpy.lib/linalg from site-packages |
|
||||
| [b5842e6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b5842e6) | fix(build): normalize non-PEP440 versions, fix .py/compileall ordering, wipe NSIS payload dirs |
|
||||
| [7a9c368](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/7a9c368) | refactor: split color-strips.ts into focused modules under color-strips/ folder |
|
||||
| [ce53ca6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ce53ca6) | feat: add card glare effect to dashboard and perf chart cards |
|
||||
| [b04978a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b04978a) | feat: add music sync viz modes and auto_gain audio filter |
|
||||
| [6e8b159](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/6e8b159) | fix: weather CSS card shows empty source name after hard refresh |
|
||||
| [ace2471](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ace2471) | feat: add math_wave color strip source type |
|
||||
| [edc6d27](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/edc6d27) | fix: replace HA test icon with refresh, make automation rules collapsible |
|
||||
| [b7da4ab](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b7da4ab) | feat: add Integrations tab and responsive icon-only tabs |
|
||||
| [99460a8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/99460a8) | fix: make pystray a core dependency on Windows instead of optional extra |
|
||||
| [89990f8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/89990f8) | chore: remove processed-audio-sources plan files |
|
||||
| [af2c89c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/af2c89c) | fix: audio tree structure, filter i18n, and IconSelect for filter options |
|
||||
| [d04192f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d04192f) | fix: add reference check before deleting audio processing template |
|
||||
| [992495e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/992495e) | fix: isolate tests from production database |
|
||||
| [6b0e4e5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/6b0e4e5) | feat(processed-audio-sources): phase 8 - frontend design consistency review |
|
||||
| [ce1f484](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ce1f484) | feat(processed-audio-sources): phase 7 - testing and polish |
|
||||
| [1ce0dc6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/1ce0dc6) | feat(processed-audio-sources): phase 6 - frontend source type cleanup |
|
||||
| [5534639](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/5534639) | feat(processed-audio-sources): phase 5 - frontend audio processing templates |
|
||||
| [ab43578](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ab43578) | feat(processed-audio-sources): phase 4 - runtime filter integration |
|
||||
| [353c090](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/353c090) | feat(processed-audio-sources): phase 3 - processed audio source model |
|
||||
| [eb94066](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/eb94066) | feat(processed-audio-sources): phase 2 - implement 11 audio filters |
|
||||
| [86a9d34](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/86a9d34) | feat(processed-audio-sources): phase 1 - audio filter framework |
|
||||
| [c59107c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/c59107c) | feat: refactor MQTT from global config to multi-instance entity model |
|
||||
| [e7c9a56](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e7c9a56) | feat: HA source cards use health-dot indicators |
|
||||
| [492bdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/492bdb9) | feat: game integration system |
|
||||
| [b6713be](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b6713be) | feat: system_metrics value source type |
|
||||
| [db5008a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/db5008a) | feat: system theme option + fix toast timer overlap |
|
||||
| [4b7a8d7](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/4b7a8d7) | feat: value source card crosslinks + gradient_map test shows input value |
|
||||
| [f6c25cd](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f6c25cd) | feat: color value source test visualization |
|
||||
| [0a87371](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/0a87371) | feat: HA value source test — raw value axis + behavior IconSelect |
|
||||
| [11d5d6b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/11d5d6b) | fix: device card header layout — URL badge overflow and hide button gap |
|
||||
| [384362c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/384362c) | feat: new value source types (HA entity, gradient map, strip extract) + UI fixes |
|
||||
| [ea812bb](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ea812bb) | feat: check if port is busy before starting the server |
|
||||
| [a9e6e8c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a9e6e8c) | fix: KC color strip test preview — use LiveStreamManager instead of raw engine |
|
||||
| [78ce6c8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/78ce6c8) | fix: composite layer opacity/brightness widgets + CSS layout |
|
||||
| [8a17bb5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/8a17bb5) | feat: BindableFloat — universal value source binding for all scalar properties |
|
||||
| [5f70302](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/5f70302) | feat: use custom app icon for shortcuts and installer |
|
||||
| [40751fe](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/40751fe) | feat: HA light target live color preview — per-entity swatches via WebSocket |
|
||||
| [381ee75](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/381ee75) | fix: HA light target — brightness source, transition=0, dashboard type label |
|
||||
| [3e6760f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3e6760f) | refactor: key colors targets → CSS source type, HA target improvements |
|
||||
| [89d1b13](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/89d1b13) | fix: rename HA Lights → Home Assistant, HA Light Targets → Light Targets |
|
||||
| [324a308](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/324a308) | feat: entity picker for HA light mapping — searchable EntitySelect for light entities |
|
||||
| [cb9289f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/cb9289f) | feat: HA light output targets — cast LED colors to Home Assistant lights |
|
||||
| [fb98e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fb98e6e) | ci: add manual build workflow for testing artifacts |
|
||||
| [3c2efd5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3c2efd5) | refactor: move Weather and Home Assistant sources to Integrations tree group |
|
||||
| [2153dde](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/2153dde) | feat: Home Assistant integration — WebSocket connection, automation conditions, UI |
|
||||
| [f3d07fc](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f3d07fc) | feat: donation banner, About tab, settings UI improvements |
|
||||
| [f61a020](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f61a020) | feat: custom file drop zone for asset upload modal; fix review issues |
|
||||
| [f345687](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f345687) | chore: remove python3.11 version pin from pre-commit config |
|
||||
| [e2e1107](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e2e1107) | feat: asset-based image/video sources, notification sounds, UI improvements |
|
||||
| [c0853ce](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/c0853ce) | fix: improve command palette actions and automation condition button |
|
||||
| [3e0bf85](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3e0bf85) | feat: add api_input LED interpolation; fix LED preview, FPS charts, dashboard layout |
|
||||
| [be4c98b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/be4c98b) | fix: show template name instead of ID in filter list and card badges |
|
||||
| [dca2d21](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/dca2d21) | fix: clip graph node title and subtitle to prevent overflow |
|
||||
| [53986f8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/53986f8) | fix: replace emoji with SVG icons on weather and daylight cards |
|
||||
| [a4a9f6f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a4a9f6f) | fix: send gradient_id instead of palette in effect transient preview |
|
||||
| [9fcfdb8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/9fcfdb8) | ci: use sparse checkout for release notes in release workflow |
|
||||
| Hash | Message | Author |
|
||||
| ---- | ------- | ------ |
|
||||
| [dd43f38](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd43f38) | fix(calibration-wizard): all-provider discovery + spatial corner picker | alexei.dolgolyov |
|
||||
| [6cd5e05](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6cd5e05) | fix(setup): register scaffolded target with ProcessorManager + final-review hardening | alexei.dolgolyov |
|
||||
| [81b1808](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/81b1808) | feat(onboarding): guided first-run setup wizard (phase 4, final) | alexei.dolgolyov |
|
||||
| [abc204c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/abc204c) | feat(snapshot): include scene playlists + cycling state in snapshot | alexei.dolgolyov |
|
||||
| [9550688](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9550688) | feat(calibration): browser-driven auto edge-calibration UI (phase 3) | alexei.dolgolyov |
|
||||
| [9dcd76d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9dcd76d) | feat(setup): one-call setup scaffold + onboarding flag (phase 2) | alexei.dolgolyov |
|
||||
| [0409cd8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0409cd8) | feat(calibration): auto edge-calibration backend core (phase 1) | alexei.dolgolyov |
|
||||
| [6180569](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6180569) | wip(dashboard): in-progress dashboard customization changes | alexei.dolgolyov |
|
||||
| [f71e10e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f71e10e) | feat(scenes): scene playlists with timed auto-cycling | alexei.dolgolyov |
|
||||
| [ca59546](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ca59546) | feat(capture): region-of-interest (ROI) crop for screen sampling | alexei.dolgolyov |
|
||||
| [1ada5ac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ada5ac) | feat(automations): weekday + timezone scheduling for time-of-day rule | alexei.dolgolyov |
|
||||
| [e18d56c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e18d56c) | feat(processing): built-in 'look' presets (Cinematic/Vivid/Cozy/Soft/Cool) | alexei.dolgolyov |
|
||||
| [7728aec](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7728aec) | feat(wled): native realtime UDP output (DRGB/DRGBW/DNRGB) with auto-revert | alexei.dolgolyov |
|
||||
| [ffee156](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ffee156) | feat(targets): automatic brightness limiting (ABL) / per-LED power budget | alexei.dolgolyov |
|
||||
| [02e2ea3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/02e2ea3) | fix(scenes): sync brightness value-source change to live processor | alexei.dolgolyov |
|
||||
| [fdc9201](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdc9201) | fix(api): remove broken legacy /system/mqtt/settings route | alexei.dolgolyov |
|
||||
| [5686ae5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5686ae5) | fix(security): remove active weak default API key from shipped config | alexei.dolgolyov |
|
||||
| [9960f15](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9960f15) | docs(android): remove ANDROID-REVIEW planning/review docs | alexei.dolgolyov |
|
||||
| [1c1bbe2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c1bbe2) | feat(android): foreground-app automation condition | alexei.dolgolyov |
|
||||
| [4bf3fe6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4bf3fe6) | feat(android): on-device webcam capture via Camera2 (AndroidCameraEngine) | alexei.dolgolyov |
|
||||
| [0be3f83](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0be3f83) | feat(android): on-device OS notification capture (NotificationListenerService) | alexei.dolgolyov |
|
||||
| [4b2e8fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b2e8fc) | docs(android): add audio-capture design + missing-functionality review | alexei.dolgolyov |
|
||||
| [fd62db1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd62db1) | feat(audio): Android on-device system playback capture | alexei.dolgolyov |
|
||||
| [669ae20](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/669ae20) | feat(value-sources): optional normalization for magnitude sources | alexei.dolgolyov |
|
||||
| [6de61b9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6de61b9) | feat(value-sources): add sandboxed-Jinja template combinator | alexei.dolgolyov |
|
||||
| [12b40e6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/12b40e6) | docs: actualize README and API reference, embed screenshots | alexei.dolgolyov |
|
||||
| [498854f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/498854f) | refactor(storage): gate clone() behind an opt-in allowlist; expand duplicate tests | alexei.dolgolyov |
|
||||
| [15cfb82](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/15cfb82) | feat(graph): duplicate a selected subgraph server-side | alexei.dolgolyov |
|
||||
| [2e51f46](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2e51f46) | feat(graph): make the visual editor a full wiring control surface | alexei.dolgolyov |
|
||||
| [05cf121](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05cf121) | fix(installer): open WebUI once after "Launch LedGrab" | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
# Dashboard Reconciliation — Review Notes
|
||||
|
||||
*Captured 2026-05-26. Session focused on dashboard + perf-card flicker and per-poll re-rendering.*
|
||||
|
||||
*Updated 2026-05-27 — widened the audit beyond the two poll timers and found a **second driver** (server push) plus the **highest-blast-radius site** (`entity-events.ts`). Added §3.5, corrected the "out of scope" reasoning in §5, and confirmed the decision: **commit to the Lit migration**. Implementation deferred — this is still a planning doc, not a spec.*
|
||||
|
||||
This is a thinking-aloud document for whoever picks up reconciliation work next (likely me). It captures the bug class, what's already shipped, what's still latent, the decision ladder we walked through, and the recommendation we landed on. It is **not** a spec — treat any code shown as illustrative.
|
||||
|
||||
---
|
||||
|
||||
## 1. The bug class in one sentence
|
||||
|
||||
> Every place a data-driven render — a poll timer **or** a server-pushed `server:*` event — writes `el.innerHTML = ...`, the existing DOM is torn down — even when the new HTML equals the old — which restarts CSS animations, drops focus, skips transitions, and burns wasted DOM mutation cycles.
|
||||
|
||||
The symptom only becomes visually loud when the destroyed subtree contains a CSS keyframe animation (e.g. the pulsing `.perf-patches-empty-dot`). Everywhere else the cost is silent: lost transitions, broken focus, wasted layout work. The bug is **load-bearing in the architecture**, not in any single call site — that's why we keep coming back to it.
|
||||
|
||||
---
|
||||
|
||||
## 2. What landed in commit `f6486f9` (this session)
|
||||
|
||||
Tactical work — solves the worst cases, does not change the architecture.
|
||||
|
||||
### `server/src/ledgrab/static/js/features/dashboard.ts`
|
||||
- Collapsed the two fast-path branches into one. Fast path runs when `structureUnchanged && !forceFullRender` regardless of `running.length`. Previously, **zero running targets meant every poll rebuilt the entire dashboard** even when nothing changed.
|
||||
- `_lastSyncClockIds` no longer fingerprints `is_running` — pausing/resuming a clock no longer tears down every card. `_updateSyncClocksInPlace` already handles the toggle.
|
||||
- `_updateAutomationsInPlace` now called from the unified fast path. Automation badges were silently going stale on the fast path.
|
||||
- `_initFpsCharts` rewritten diff-based: only destroy charts for ids that left or whose canvas was detached by a DOM swap; only create for new ids; only fetch `/api/metrics/history` when there are genuinely new ids needing seed data.
|
||||
- Sync-clock pause/resume/reset callers + `server:automation_state_changed` SSE handler now use `loadDashboard()` (no force) — `forceFullRender` is now actually load-bearing, meaning "settings changed, full rebuild required."
|
||||
|
||||
### `server/src/ledgrab/static/js/features/perf-charts.ts`
|
||||
- `_renderChartSvg` no longer rewrites `innerHTML` per poll. The SVG skeleton (ref line + sys area/line + app line) is built once via `_ensureSparkNodes` and mutated thereafter. WeakMap cache (`_sparkNodeCache`) keyed by host element avoids the per-tick `querySelector` cost.
|
||||
- Hidden cards (env-disabled GPU/Temp) skip render entirely.
|
||||
- `_fetchPerformance` switched to `fetchWithAuth`.
|
||||
- Hardcoded English strings replaced with `t()` calls. New keys: `perf.no_captures`, `perf.captures_count.{one,few,many,other}`, `perf.ratio_of_requested`, `perf.total_count`, `perf.skipped_per_sec`, `perf.tip.now`, `perf.tip.ago` (en/ru/zh).
|
||||
- Tooltip reads `dashboardPollInterval` per mousemove tick (was captured at bind time).
|
||||
- Dead `<defs><linearGradient>` block removed.
|
||||
- `updateTotalCaptureFpsActual` now delegates to `_paintCaptureFpsActualValue` — single code path.
|
||||
- `updateActivePatches` / `updateDevices` skip the `innerHTML` write when content signature hasn't changed. This is the direct fix for the "READY TO LAUNCH flickers every update" report — the empty-state dot's CSS pulse no longer resets.
|
||||
- Two missing semicolons in `_seedAggregateHistories` (ASI was saving us).
|
||||
|
||||
### Reviewer findings addressed (typescript-reviewer pass)
|
||||
- **HIGH:** `_metricLabel` was looking up `dashboard.perf.${key}` but the FPS family uses `dashboard.perf.total_fps`, `total_capture_fps`, `total_capture_fps_actual`. Tooltip would have shouted `FPS` / `CAPTURE_FPS` / `CAPTURE_FPS_ACTUAL`. Fixed via explicit `METRIC_LABEL_KEYS` map.
|
||||
- **HIGH:** `_ensureSparkNodes` silently coerced `null` children to non-null when the SVG existed but a child was missing. Hardened to validate all four children and rebuild if any are missing.
|
||||
|
||||
---
|
||||
|
||||
## 3. Hot spots still latent
|
||||
|
||||
These are the call sites where `innerHTML` is still written every poll. None are flickering today (no CSS animations on their inner elements), but every one is the same bug shape and will bite the next time someone adds a keyframe / transition / focus target inside.
|
||||
|
||||
### `perf-charts.ts`
|
||||
|
||||
| Line | Site | Fires per poll? | Notes |
|
||||
|------|------|-----------------|-------|
|
||||
| 462 | `updateActivePatches` → `listEl.innerHTML` | yes | guarded by signature compare (✓) |
|
||||
| 493 | `updateTotalFps` → `valEl.innerHTML` | yes | FPS value, no inner animation |
|
||||
| 526 | `updateTotalCaptureFps` → `valEl.innerHTML` | yes | same |
|
||||
| 638 | `_paintNetworkValue` → `valEl.innerHTML` | yes | bytes/s value |
|
||||
| 655 | `_paintDeviceLatencyValue` → `valEl.innerHTML` (no-devices hint) | yes | hint span |
|
||||
| 657 | `_paintDeviceLatencyValue` → `valEl.innerHTML` (offline hint) | yes | hint span |
|
||||
| 660 | `_paintDeviceLatencyValue` → `valEl.innerHTML` (ms value) | yes | value |
|
||||
| 676 | `_paintSendTimingValue` → `valEl.innerHTML` (idle hint) | yes | hint span |
|
||||
| 679 | `_paintSendTimingValue` → `valEl.innerHTML` (ms value) | yes | value |
|
||||
| 738 | `_paintErrorsValue` → `valEl.innerHTML` | yes | rate value |
|
||||
| 806 | `updateDevices` → `dotsEl.innerHTML` | yes | guarded by signature compare (✓) |
|
||||
| 1086 | `_renderValuePair` → `mainEl.innerHTML = appVal` | yes | dual sys/app value |
|
||||
| 1088 | `_renderValuePair` → `mainEl.innerHTML = sysVal` | yes | dual sys/app value |
|
||||
| 1094 | `_renderValuePair` → `tagEl.innerHTML` (App tag) | mode='both' only | App tag in `both` mode |
|
||||
| 1181 | `_applyPerfDataToDom` temp hint | only when cpu_temp_hint_key changes | rare |
|
||||
| 1449 | `_paintFpsValue` | seed only | once per init |
|
||||
| 1456 | `_paintCaptureFpsValue` | seed only | once per init |
|
||||
| 1463 | `_paintCaptureFpsActualValue` (no-captures hint) | yes via live updater | now goes through painter |
|
||||
| 1469 | `_paintCaptureFpsActualValue` (value) | yes via live updater | same |
|
||||
| 1499 | `_paintErrorsValue` (duplicate of 738) | seed only | once per init |
|
||||
| 1823 | tooltip `tip.innerHTML` | per mousemove | rate-limited by hover only |
|
||||
|
||||
### `dashboard.ts`
|
||||
|
||||
| Line | Site | Fires per poll? | Notes |
|
||||
|------|------|-----------------|-------|
|
||||
| 275 | `_updateRunningMetrics` → `fpsEl.innerHTML` | per running target | live FPS pill — visible churn |
|
||||
| 293 | `_updateRunningMetrics` → `labelEl.innerHTML` (errors label) | per running target | rebuilt each poll |
|
||||
| 340 | `_updateAutomationsInPlace` → `btn.innerHTML` | only on enable/disable change | low frequency |
|
||||
| 366 | `_updateSyncClocksInPlace` → `btn.innerHTML` | per poll for every clock | wasteful |
|
||||
| 975 | `loadDashboard` first-load → `container.innerHTML` | once per init | fine |
|
||||
| 989 | `loadDashboard` slow path → `dynamic.innerHTML = dynamicHtml` | only when slow path fires | the **big** swap, scoped already |
|
||||
| 1010 | `loadDashboard` error path | rare | fine |
|
||||
| 1416 | `subscribeDashboardLayout` clear | rare | fine |
|
||||
|
||||
### What this list tells us
|
||||
|
||||
- The remaining innerHTML writes are **per-cell value updates** that paint formatted spans (`{value}<span class="perf-fps-unit">fps</span>`). Each rewrite destroys two text nodes + a span every poll across ~10 cells. Not flickering today; will flicker the moment anyone adds an animation to `.perf-fps-unit` or `.perf-fps-ceiling`.
|
||||
- The pattern can be killed without architectural change by splitting these into a stable structure (number text node + static unit span) and only updating `textContent` of the number. That's what L3 / Lit would force naturally.
|
||||
|
||||
---
|
||||
|
||||
## 3.5 Beyond dashboard/perf — push-driven reconciliation
|
||||
|
||||
*Added 2026-05-27. The §3 audit was scoped to the two poll timers we were debugging. Widening the `\.innerHTML\s*=` search showed the bug class has a **second driver** and lives outside dashboard/perf too.*
|
||||
|
||||
### Two drivers, not one
|
||||
|
||||
The teardown is triggered by anything that re-renders **without user intent**:
|
||||
|
||||
- **Poll timers** (`setInterval`) — what §2/§3 covered (`dashboard.ts` `_uptimeTimer` + main refresh, `perf-charts.ts` `_pollTimer`).
|
||||
- **Server-pushed `server:*` events** — `core/events-ws.ts` turns each WS message into a `server:*` CustomEvent; feature modules listen and re-render through the *same* `innerHTML` paths.
|
||||
|
||||
So the one-line bug class in §1 reads "poll- **or** push-driven," not just poll.
|
||||
|
||||
### Genuinely-affected sites outside dashboard/perf
|
||||
|
||||
| Site | Driver | Shape | Notes |
|
||||
| ---- | ------ | ----- | ----- |
|
||||
| `core/entity-events.ts` `_invalidateAndReload` | push (`server:entity_changed`, `server:device_health_changed`) | full-**tab** rebuild via `loadTargetsTab` / `loadPictureSources` / `loadAutomations` / `loadIntegrations` | **highest blast radius.** A single pushed entity change tears down and rebuilds an entire tab — losing scroll, focus, open inline editors, restarting card-enter animations. |
|
||||
| `features/game-integration.ts` event feed (`_eventMonitorTimer`) | poll (2 s) | `feed.innerHTML = events.slice(0,20).map(...)` | full 20-item list rebuild every 2 s while the panel is open. |
|
||||
| `features/game-integration.ts` connection test (`_connectionTestTimer`) | poll | `panel.innerHTML = …` per tick | transient, low frequency. |
|
||||
|
||||
`entity-events.ts` already has the **L1 floor applied by hand**: a 600 ms debounce plus a diff check (`oldData === newData`, then length + `id` + `updated_at` compare) that skips the reload when nothing changed. That kills the *no-op* case — but a **real** change still does the full-tab teardown. This is exactly the §4-L1 limitation ("still tears down when content *does* differ"), live across the whole app.
|
||||
|
||||
### Counter-examples that already do it right
|
||||
|
||||
Two poll loops never flicker because they mutate `textContent` on a **stable structure** instead of rewriting `innerHTML`:
|
||||
|
||||
- `core/api.ts` `loadServerInfo` (connection-check poll) — `versionEl.textContent` / `statusEl.textContent`.
|
||||
- `features/color-strips/test.ts` FPS sampler (1 s) — `valueEl.textContent` / `avgEl.textContent`.
|
||||
|
||||
These are live proof that "stable structure + mutate text node" is the fix — i.e. what L3 / Lit force by construction.
|
||||
|
||||
### What this changes about the plan
|
||||
|
||||
The §4 ladder was reasoned entirely around **per-cell** rendering, because that was the visible flicker. The push-driven finding surfaces a second, qualitatively different problem:
|
||||
|
||||
- **Problem A — cell value churn:** every poll, one value span. Loud only with animations. *Mostly fixed in `f6486f9`.* → wants `setText` / skip-if-unchanged.
|
||||
- **Problem B — list/tab teardown:** on change/push, an entire list or tab. Loses scroll/focus/open editors. *Unaddressed.* `entity-events.ts` and the game feed are Problem B. → wants **keyed list reconciliation**.
|
||||
|
||||
Problem B is a **list-level** concern, not a cell-level one. In Lit terms it maps to a keyed `repeat()` directive over the tab/list body — the dashboard-card work in Phase 2 already needs this, but `entity-events.ts` needs it for tabs that §5 used to list as "out of scope." This does **not** change the chosen direction (Lit); it adds `entity-events.ts` as a first-class, high-priority target.
|
||||
|
||||
---
|
||||
|
||||
## 4. Decision ladder
|
||||
|
||||
Walked through with the user 2026-05-26. Captured here so we don't re-litigate.
|
||||
|
||||
### L1 — drop-in `setInnerHtmlIfChanged` helper
|
||||
- **Shape:** `WeakMap<Element, string>` cache; replace every `el.innerHTML = x` with `setInnerHtmlIfChanged(el, x)`.
|
||||
- **Wins:** stops the no-change rewrites globally; zero behavior risk; ~30 call-site changes.
|
||||
- **Misses:** still tears down DOM when content *does* differ (e.g. FPS row values change every tick); doesn't preserve focus/transition state inside a list.
|
||||
- **Verdict:** floor, not ceiling. Worth doing for cells that don't get migrated to L3/Lit.
|
||||
|
||||
### L2 — lint guard
|
||||
- **Shape:** pre-commit script greps `\.innerHTML\s*=` in `static/js/` outside an allowlist, fails the commit.
|
||||
- **Wins:** keeps the discipline; cheap.
|
||||
- **Misses:** only useful as a pair with L1+; bare guard with no helper makes contributors angry.
|
||||
- **Verdict:** pair with whatever helper we land on.
|
||||
|
||||
### L3 — hand-rolled cell-component pattern
|
||||
- **Shape:** `defineCell({ html, refs, mount, update, unmount })` + `reconcileList(host, items, binding)` + `setText/setClass/setAttr` mutators. ~150–300 lines of runtime.
|
||||
- **Wins:** correct by construction; no dependencies; explicit about what mutates; composes with existing customize panel / color picker.
|
||||
- **Misses:** we own the abstraction — it grows over time as we need transitions, async data, focus, devtools, error boundaries. Death by a thousand features.
|
||||
- **Verdict:** second-best. Strong contender if zero-deps is a hard constraint.
|
||||
|
||||
### Lit migration of polling modules — **recommended**
|
||||
- **Shape:** convert each perf cell + each dashboard card cell to a Lit web component. Use `html\`<span>${value}</span>\`` tagged-template + targeted diff. ~5KB gzip added to bundle, no new build step (esbuild handles it).
|
||||
- **Wins:** solves the bug class by design; maintained by Google + community; web-components-based so no framework lock-in; composes with vanilla DOM trivially; mental model is close to current template-string idiom; non-polling code can stay vanilla forever.
|
||||
- **Misses:** introduces a dependency; contributors learn one more thing; rare edge cases (`@html`-equivalent exists and reintroduces the bug if misused).
|
||||
- **Verdict:** best ceiling-to-cost ratio for a small team. Recommended.
|
||||
|
||||
### Full framework rewrite (React / Vue / Solid)
|
||||
- **Verdict:** overkill. The bug class lives in polling paths; the rest of the app is fine. Spending the migration budget on rebuilding IconSelect / EntitySelect / modals / customize panel / graph editor — none of which are broken — is a bad trade.
|
||||
|
||||
---
|
||||
|
||||
## 5. Recommendation
|
||||
|
||||
**Lit for the polling-heavy modules.**
|
||||
|
||||
Migration plan:
|
||||
|
||||
### Phase 0 — spike (2-hour time-box)
|
||||
- Convert `patches` cell to a Lit component, end to end.
|
||||
- Verify it plays nicely with: color picker integration, customize panel layout reorder, `rerenderPerfGrid` reconciliation, `setPerfMode` toggle, hidden-by-env state, the spark tooltip handler.
|
||||
- If any of those break in an unfixable way → pivot to L3.
|
||||
- If they work → commit to the migration.
|
||||
|
||||
### Phase 1 — perf-charts cells
|
||||
1. `patches` (already spiked)
|
||||
2. `devices`
|
||||
3. `fps` / `capture_fps` / `capture_fps_actual` (share a sparkline base class)
|
||||
4. `cpu` / `ram` / `gpu` / `temp` (share `_sparkCardHtml` template family)
|
||||
5. `network` / `device_latency` / `send_timing` / `errors`
|
||||
|
||||
Each is its own PR, dashboard stays working at every step. `renderPerfSection` becomes a registry of Lit components; `rerenderPerfGrid` becomes "reorder existing elements in the grid" (which it mostly already does).
|
||||
|
||||
### Phase 2 — dashboard card cells
|
||||
6. Output target cards (running variant — biggest payoff, has live FPS + uptime + errors)
|
||||
7. Output target cards (stopped variant)
|
||||
8. Sync clock cards
|
||||
9. Automation cards
|
||||
10. Integration (HA / MQTT) cards
|
||||
|
||||
These get bigger wins from the migration because they have nested mutable state (FPS pill, errors cell, health dot, action button) that's currently rebuilt per poll via the `_updateRunningMetrics` path.
|
||||
|
||||
### Highest-impact: `entity-events.ts` tab reconciliation (sequence early)
|
||||
|
||||
`entity-events.ts` (§3.5) is the single highest-blast-radius site and is **not** on the dashboard — it re-renders the Targets / Integrations / Automations tabs on server push. Whether or not those tabs' cells become Lit components, the loader path (`loadTargetsTab` / `loadIntegrations` / `loadAutomations`) should switch from a full `innerHTML` rebuild to a **keyed list reconcile** (a Lit `repeat()` over the tab body). This preserves scroll / focus / open inline editors across pushes. If the goal is "biggest UX win first" rather than "lowest-risk first," sequence this ahead of Phase 2.
|
||||
|
||||
### Phase 3 — stopgap helper for the rest
|
||||
Add `setInnerHtmlIfChanged` and apply to any remaining vanilla polling sites we don't plan to migrate. Add the L2 lint guard at this point — by now everything that polls is either Lit-managed or uses the helper.
|
||||
|
||||
### Out of scope (deliberately) — with one correction (2026-05-27)
|
||||
|
||||
- Targets tab, automations editor, integrations, scene presets — these render on-demand, **but they are ALSO re-rendered on server push** via `entity-events.ts` (see §3.5). The original claim that "the bug class doesn't bite them" was **wrong**: a pushed `server:entity_changed` does a full-tab `innerHTML` teardown. The *editor / on-demand views* can stay vanilla, but the **list/tab render that entity-events triggers needs reconciliation** (a keyed list diff) regardless of whether those cells become Lit components. Treat the entity-events reload path as **in-scope** — it is the highest-blast-radius Problem B site.
|
||||
- Color strips editor, graph editor, settings — genuinely on-demand, no push re-render path, stay vanilla.
|
||||
- Transport bar cells (CPU/Mem chip in the top bar) — read from the same perf payload, can be migrated opportunistically but not urgent.
|
||||
|
||||
---
|
||||
|
||||
## 6. Open questions to settle before committing
|
||||
|
||||
These came up during the discussion and weren't resolved:
|
||||
|
||||
1. **Bundle-size budget.** Is +5KB acceptable? Current bundle is 2.7MB so this is noise — but worth confirming there isn't a strict cap (e.g. for slow networks / Android Chaquopy embed).
|
||||
2. **Contributor model.** If the project will grow to multiple contributors, Lit's smaller community vs React's is a recruiting tradeoff. Currently solo-ish, so probably moot.
|
||||
3. **Android TV target.** Chaquopy embed serves the same bundle. Lit works fine in any modern browser — Android TV WebView is Chromium-based. Should be a no-op but verify in Phase 0 spike.
|
||||
4. **Long-term framework intent.** If there's a chance we ever migrate to React/Vue/Solid for the rest of the app, doing Lit now is *not* lock-in (web components are standard), but it does add a second mental model. Probably fine; just naming the tradeoff.
|
||||
5. **Customize panel.** The drag-reorder code in `dashboard-customize.ts` mutates `.dashboard-section` DOM directly. Lit components reorder cleanly via `moveBefore` / `insertBefore` since they're just elements, but the dnd library needs to treat them as opaque drag handles. Phase 0 spike should confirm.
|
||||
|
||||
---
|
||||
|
||||
## 7. Pointers
|
||||
|
||||
- Source files most relevant:
|
||||
- `server/src/ledgrab/static/js/features/dashboard.ts`
|
||||
- `server/src/ledgrab/static/js/features/perf-charts.ts`
|
||||
- `server/src/ledgrab/static/js/features/dashboard-layout.ts` (cell ordering + visibility)
|
||||
- `server/src/ledgrab/static/js/features/dashboard-customize.ts` (drag-reorder UI)
|
||||
- `server/src/ledgrab/static/js/core/card-modes.ts` (mode toggle that hangs off section headers)
|
||||
- `server/src/ledgrab/static/js/core/entity-events.ts` (push-driven tab reloads — §3.5, highest blast radius)
|
||||
- `server/src/ledgrab/static/js/core/events-ws.ts` (WS → `server:*` CustomEvent dispatch)
|
||||
- `server/src/ledgrab/static/js/features/game-integration.ts` (2 s event-feed list rebuild — §3.5)
|
||||
- Most recent reconciliation commit: `f6486f9`.
|
||||
- Related skill files in `~/.claude/skills/`: `frontend-patterns`, `documentation-lookup` (for Lit docs via Context7).
|
||||
- Locale convention: `perf.*` for cross-card primitives, `dashboard.perf.*` for cell titles.
|
||||
|
||||
---
|
||||
|
||||
## 8. If this doc gets stale
|
||||
|
||||
If you read this and the perf cells are already Lit components — delete this file. If you read this and there's a new flicker / focus / transition bug nobody can explain — search for `\.innerHTML\s*=` in `static/js/features/` **and `static/js/core/`** (`entity-events` lives in core) and you've probably found it. For *state loss on a server event* (scroll jump, focus drop, an inline editor closing itself), look at the `server:*` listeners in `core/entity-events.ts` first.
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
# Production Review — Remaining Items
|
||||
|
||||
Output of the multi-agent production review (security / Python / TypeScript /
|
||||
performance / architecture / code-quality). Each entry below is something
|
||||
the original audit flagged and the autonomous hardening pass deliberately
|
||||
did **not** address — either because it needs design input, profiling
|
||||
validation, or a multi-day refactor that should land in its own session.
|
||||
|
||||
The hardening pass landed everything else: see git log between `master` and
|
||||
the head of the review branch for the applied changes (URL-scheme +
|
||||
malicious-input rejection, IconSelect XSS escape, MiniSelect for forbidden
|
||||
plain `<select>`s, WebSocket Origin allow-list, /docs auth-gate, security
|
||||
headers middleware, streaming upload size caps, fire-and-forget task
|
||||
tracking + drain resilience in MQTT runtime, discovery_watcher task
|
||||
tracking, asyncio.gather return_exceptions, secret_box encryption for MQTT
|
||||
/ Hue / Govee credentials with auto-migration, SSRF-validated update
|
||||
redirects, single source of truth for IP classification in
|
||||
`utils/net_classify.py`, allowlist + parity test for inbound WS events,
|
||||
typed `Window` globals, and more).
|
||||
|
||||
## Items completed in the follow-up autonomous pass (2026-05-23)
|
||||
|
||||
- [x] **devices.py PATCH-without-url processor desync** — `update_device`
|
||||
now falls back to `existing.url` so a rename / icon-only edit
|
||||
always tells the processor the current address.
|
||||
- [x] **WLED scheme integration test** on `/api/v1/devices` — covers
|
||||
bare IPv4 (`http://`), public hostname (`https://`), and trailing-slash
|
||||
normalisation; lives in `tests/api/routes/test_devices_routes.py`.
|
||||
- [x] **IPv6 regression test** — `tests/test_url_scheme.py` now pins
|
||||
public IPv6 → `https://`, ULA → `http://`, and documents the
|
||||
Python-`ipaddress` documentation-prefix classification quirk.
|
||||
- [x] **IconSelect XSS audit + defence-in-depth** — every caller
|
||||
audited (all feed `icon` from constants or lookup tables); added
|
||||
`sanitiseIcon` that rejects `<script>`, `javascript:`, `on*=`,
|
||||
`<iframe>`, `<embed>`, `<object>` and warns to the console.
|
||||
- [x] **`Optional[T]` → `T | None` (PEP 604)** — 55 sites cleaned via
|
||||
`ruff --fix UP007`. The remaining `Union[…]` aliases for
|
||||
pixel/colour/device-config typing converted by hand. `UP007` now
|
||||
lives in `pyproject.toml` so the rule fires on new code.
|
||||
- [x] **Hot-path magic numbers → named constants** — `processed_stream`
|
||||
gains `_FILTER_RECHECK_EVERY_N_FRAMES`; `wled_target_processor`
|
||||
gains `_SKIP_REPOLL_SLEEP_SECONDS`, `_DIAGNOSTICS_REPORT_INTERVAL_SECONDS`,
|
||||
`_CSPT_RECHECK_EVERY_N_ITERATIONS`.
|
||||
- [x] **`api/auth.py` `except Exception` tightening** — every WS send /
|
||||
close site is now `except _WS_SEND_BENIGN_EXC` (a narrow tuple of
|
||||
WebSocketDisconnect / RuntimeError / ConnectionError / OSError).
|
||||
The auth-receive path catches the same set plus a final
|
||||
`logger.exception` catch-all for observability on truly unexpected
|
||||
shapes.
|
||||
- [x] **`(window as any)` cleanup** — 59 static-property accesses
|
||||
migrated to typed `window.<name>` against `global-types.d.ts`. The
|
||||
remaining 7 sites use dynamic string indexing (`window[fnName]`)
|
||||
and intentionally keep the cast (documented in the typedef file).
|
||||
|
||||
---
|
||||
|
||||
## Architecture refactors (multi-day — own session)
|
||||
|
||||
- [ ] **Split `core/processing/value_stream.py`** (1856 LOC, 14 stream classes)
|
||||
into a `value_streams/` package. Each value-stream type gets its own
|
||||
file ≤300 LOC; `manager.py` holds `ValueStreamManager`.
|
||||
- [ ] **Split `storage/color_strip_source.py`** (1841 LOC, 18 source kinds)
|
||||
into a `color_strip_sources/` package mirroring `value_streams/`.
|
||||
- [ ] **Frontend file splits** — `graph-editor.ts` (2707), `streams.ts`
|
||||
(2335), `value-sources.ts` (1889), `types.ts` (1062). Highest-churn
|
||||
modules; mixed UI / state / network responsibilities.
|
||||
- [ ] **Layering reversal**: introduce a neutral `domain/` package and move
|
||||
shared DTOs (`FilterInstance`, `CalibrationConfig`, etc.) into it so
|
||||
`storage/` no longer imports `core/`. Eliminates 7+ layering
|
||||
violations and the lazy-import hacks used to break the resulting
|
||||
circulars.
|
||||
- [ ] **`main.py` boot refactor** — extract import-time side effects into
|
||||
`bootstrap.py` + `create_app()` factory. `lifespan()` becomes the
|
||||
single place that wires stores and managers.
|
||||
- [ ] **DI consolidation** — replace `api/dependencies.py` getter sprawl
|
||||
(30+ `get_*()` functions reading a process-global `_deps` dict) with
|
||||
a single typed `get_container()` dependency. Makes test-overrides
|
||||
trivial; ban direct getter calls in handler bodies.
|
||||
- [ ] **Exception hierarchy** — define `ledgrab/errors.py` (`LedGrabError`,
|
||||
`NotFoundError`, `ValidationError`, `RemoteUnavailableError`,
|
||||
`SSRFBlockedError`). Move HTTP translation into a FastAPI exception
|
||||
handler. Stop raising `HTTPException` from `utils/safe_source.py`.
|
||||
- [ ] **Lazy-import audit** — 289 in-function `from ledgrab.*` imports.
|
||||
Specifically `core/processing/daylight_settings.py` imports
|
||||
`api.dependencies` (core → api inversion). Pass the database in via
|
||||
the constructor instead of service-locator lookup.
|
||||
|
||||
## Performance (profile before applying)
|
||||
|
||||
- [ ] **`composite_stream.py` blend modes** — pre-allocate scratch buffers
|
||||
in `_blend_override / overlay / hard_light / soft_light / difference
|
||||
/ exclusion`. Each currently allocates per frame (`mul`, `scr`,
|
||||
`blended`, `np.where(...)`). At 100 LEDs × 30 fps × N layers this
|
||||
adds up.
|
||||
- [ ] **`mapped_stream` / `composite_stream` zone resize** — replace the
|
||||
per-channel `np.interp` calls with a cached `floor/ceil/frac` LUT
|
||||
(same trick as `wled_target_processor._fit_to_device`) or a single
|
||||
`cv2.resize` call on the (N,3) array. `np.interp` allocates a new
|
||||
`float64` array per channel per frame even on cache-hit.
|
||||
- [ ] **`processed_stream._processing_loop`** — add ping-pong output
|
||||
buffers and pass them as `out=` to filter `process_strip()` calls.
|
||||
Today every filter that returns a fresh allocation costs us a copy
|
||||
per frame. Also: the loop uses `time.sleep` instead of an
|
||||
event-driven wait on the input stream — input updates faster than
|
||||
30 fps see up to `frame_time` of latency.
|
||||
- [ ] **`mqtt_client.py` `send_pixels`** — add a binary publish path (or
|
||||
at minimum cache the outer dict skeleton). Today every frame
|
||||
`pixels.tolist()` + `json.dumps` for ~300 LEDs × 30 fps × N devices.
|
||||
- [ ] **Frontend `static/js/features/color-strips/test.ts`** — cache
|
||||
`ImageData` per canvas (`canvas._imageData`); only re-create on
|
||||
dimension change; use a `Uint32Array` view to copy pixels in one
|
||||
loop instead of the per-pixel JS loop. Border-overlay rebuild on
|
||||
every frame should also be debounced to dimension changes only.
|
||||
- [ ] **`ws_stream.py` composite branch** — pre-allocate a `bytearray`
|
||||
sized to the largest frame and write into slices instead of
|
||||
`b"".join(tobytes()) per layer` every iteration. Same anti-pattern
|
||||
in `wled_target_processor._broadcast_led_preview`.
|
||||
- [ ] **Preview broadcast slow-client guard** — `asyncio.gather` over
|
||||
preview clients waits for the slowest. Move to `asyncio.wait` with a
|
||||
timeout and drop slow clients, or fire-and-forget with a
|
||||
`ws.application_state` filter.
|
||||
|
||||
## Security (deferred — non-trivial or design-sensitive)
|
||||
|
||||
- [ ] **Content-Security-Policy header** — would need careful tuning
|
||||
because the UI uses inline event handlers / Jinja templates.
|
||||
Mis-set CSP would break the app silently. Defer until templates can
|
||||
move to event-delegated handlers, then add a strict policy.
|
||||
- [x] **`api/auth.py` exception specificity** — done in the 2026-05-23
|
||||
pass; see top of file.
|
||||
- [ ] **Hue bridge cert pinning** — `httpx.AsyncClient(verify=False)` for
|
||||
Hue bridge (self-signed cert by design). Should record the
|
||||
certificate fingerprint at pairing time and pin it on subsequent
|
||||
requests; otherwise an on-path attacker can MITM the bridge.
|
||||
|
||||
## Mechanical / code-quality (low risk, high line-count)
|
||||
|
||||
- [ ] **i18n parity** — confirmed **328** keys missing in `ru.json` and
|
||||
**325** missing in `zh.json` against the canonical English file.
|
||||
Translation work — needs a native speaker, not a machine-translation
|
||||
pass. Run `py scripts/diff_locale_keys.py` (or copy the diff block
|
||||
out of the 2026-05-23 pass log) to get the exact key list.
|
||||
- [x] **`Optional[T]` → `T | None`** — done; `UP007` now enforced via
|
||||
`pyproject.toml` so the rule prevents regressions.
|
||||
- [ ] **Hot-path `logger.error(f"...")` → `logger.error("... %s", e)`**
|
||||
lazy-eval — 658 sites flagged by `ruff --select G004`. Deferred
|
||||
because it is genuinely cosmetic at ERROR level (always emitted)
|
||||
and the cumulative cost is negligible. Worth doing if/when ruff
|
||||
gains a safe autofix, or as a Codemod in a dedicated session.
|
||||
- [x] **Remaining `(window as any)` sites** — 59 migrated to typed
|
||||
`window.<name>` access; the 7 surviving sites use dynamic string
|
||||
indexing and are documented as the legitimate exception.
|
||||
- [x] **Magic numbers → named constants** — done; see `processed_stream`
|
||||
and `wled_target_processor` constants at the top of each module.
|
||||
- [ ] **Standardise `from __future__ import annotations`** — partially
|
||||
mooted by the UP007 cleanup. Files that previously relied on
|
||||
`Optional`/`Union` no longer need the future import; the few that
|
||||
already use `__future__` keep it for forward-reference convenience.
|
||||
A blanket policy would still help — leave as a stylistic followup.
|
||||
|
||||
## Test gaps
|
||||
|
||||
- [x] **Route-level integration test** for the WLED scheme inference —
|
||||
done; covers create + update in `tests/api/routes/test_devices_routes.py::TestWLEDSchemeInference`.
|
||||
- [x] **IPv6 public address regression** — done; pinned in
|
||||
`tests/test_url_scheme.py` for both bracketless and bracketed forms.
|
||||
|
||||
## Pre-existing issues surfaced during the audit (not in our diff)
|
||||
|
||||
These were flagged by the auditors but predate the review session — kept
|
||||
here as a future-work backlog:
|
||||
|
||||
- [x] **`icon-select.ts:_buildGrid` `item.icon` is interpolated raw** —
|
||||
audited; all callers pass project-owned literals or table-lookup
|
||||
results. Added a runtime sanitiser as defence-in-depth.
|
||||
- [x] **`devices.py` `manager.update_device_info(device_url=update_data.url)`**
|
||||
None-on-PATCH path — fixed; now falls back to `existing.url`.
|
||||
- [ ] **`asyncio.gather` over uncapped client lists** in preview broadcasts
|
||||
— slow clients block the loop. Already noted under Performance
|
||||
above; pre-existing.
|
||||
@@ -1,116 +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
|
||||
|
||||
- [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
|
||||
|
||||
---
|
||||
|
||||
# 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`
|
||||
|
||||
- [x] ~~Crossfade transition~~ — won't do: external client owns temporal transitions; crossfading on our side would double-smooth
|
||||
- [x] Interpolation when incoming LED count differs from strip count (linear/nearest/none modes)
|
||||
- [x] Last-write-wins from any client — already the default behavior (push overwrites buffer)
|
||||
|
||||
## 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~~ **DONE** — WebSocket API chosen, connection layer + automation condition + UI implemented
|
||||
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?
|
||||
3. **Home Assistant output targets** — Investigate casting LED colors TO Home Assistant lights (reverse direction). Use HA `light.turn_on` service call with `rgb_color` via WebSocket API. Could enable: ambient lighting on HA-controlled bulbs (Hue, WLED via HA, Zigbee lights), room-by-room color sync, whole-home ambient scenes. Need to research: rate limiting (don't spam HA with 30fps updates), grouping multiple lights, brightness/color_temp mapping, transition parameter support.
|
||||
@@ -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
|
||||
@@ -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,220 @@
|
||||
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"
|
||||
// SDK 35 (Android 15) — required for Play Store from Aug 2025 onward.
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.ledgrab.android"
|
||||
minSdk = 24 // Android 7.0 — covers nearly all TV boxes
|
||||
targetSdk = 35
|
||||
// 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.8.2"
|
||||
|
||||
// ABI selection. Detect armeabi-v7a wheel presence and opt the
|
||||
// ABI in only when the matching pydantic-core wheel is on disk —
|
||||
// otherwise Chaquopy would fail the build searching for it. The
|
||||
// build script (build-scripts/build-pydantic-core.sh) is the
|
||||
// source of truth for which ABIs we *can* ship.
|
||||
val v7Wheel = file("$rootDir/wheels").listFiles().orEmpty()
|
||||
.any { it.name.startsWith("pydantic_core-") && it.name.contains("armeabi_v7a") }
|
||||
val ledgrabAbis = buildList {
|
||||
add("arm64-v8a")
|
||||
add("x86_64")
|
||||
add("x86")
|
||||
if (v7Wheel) add("armeabi-v7a")
|
||||
}
|
||||
ndk {
|
||||
// arm64-v8a is the primary target (real TV hardware).
|
||||
// x86_64/x86 cover emulators.
|
||||
// armeabi-v7a is opt-in: many pre-2018 Mecool/X96/H96 TV boxes
|
||||
// still ship 32-bit ARMv7 — when a wheel exists in wheels/ we
|
||||
// automatically include the ABI in builds.
|
||||
abiFilters += ledgrabAbis
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
val v7Wheel = file("$rootDir/wheels").listFiles().orEmpty()
|
||||
.any { it.name.startsWith("pydantic_core-") && it.name.contains("armeabi_v7a") }
|
||||
isEnable = true
|
||||
reset()
|
||||
include("arm64-v8a", "x86_64", "x86")
|
||||
if (v7Wheel) include("armeabi-v7a")
|
||||
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",
|
||||
)
|
||||
// Refuse to silently sign release APKs with the debug
|
||||
// keystore — that's how a debug-signed release accidentally
|
||||
// ships. CI must provide all four signing env vars. If a
|
||||
// local "release" build is genuinely intended for testing,
|
||||
// set ANDROID_ALLOW_DEBUG_SIGNED_RELEASE=1 to opt out.
|
||||
val allowDebugSigned =
|
||||
System.getenv("ANDROID_ALLOW_DEBUG_SIGNED_RELEASE") == "1"
|
||||
signingConfig = when {
|
||||
hasCiSigning -> signingConfigs.getByName("release")
|
||||
allowDebugSigned -> signingConfigs.getByName("debug")
|
||||
else -> throw GradleException(
|
||||
"Release builds require signing env vars " +
|
||||
"(ANDROID_KEYSTORE_PATH/PASSWORD, ANDROID_KEY_ALIAS/PASSWORD). " +
|
||||
"Set ANDROID_ALLOW_DEBUG_SIGNED_RELEASE=1 to force a debug-signed release."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
// SplashScreen API — keeps a friendly logo on screen while Chaquopy
|
||||
// unpacks the Python stdlib on first launch (can take 1-3s).
|
||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||
// QR code generation for displaying server URL on TV
|
||||
implementation("com.google.zxing:core:3.5.3")
|
||||
// EncryptedSharedPreferences (Android Keystore-backed) for the per-install
|
||||
// server API key (see ApiKeyManager). Falls back to plain SharedPreferences
|
||||
// when the keystore is unavailable.
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
// 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,186 @@
|
||||
<?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"
|
||||
tools:targetApi="s" />
|
||||
|
||||
<!-- 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" />
|
||||
|
||||
<!-- Foreground service permissions.
|
||||
FOREGROUND_SERVICE_MEDIA_PROJECTION: required on API 34+ for the
|
||||
MediaProjection capture path.
|
||||
FOREGROUND_SERVICE_SPECIAL_USE: required on API 34+ for the root
|
||||
screenrecord capture path (it doesn't use MediaProjection).
|
||||
Both are declared because the service may run in either mode. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<!-- FOREGROUND_SERVICE_CAMERA (API 34+): required to keep camera access while
|
||||
the app is backgrounded during on-device webcam capture. The service is
|
||||
promoted with the `camera` FGS type ONLY when CAMERA is already granted
|
||||
(see CaptureService.onStartCommand) — unlike audio playback capture (which
|
||||
rides the MediaProjection token under the mediaProjection type), the camera
|
||||
has no such coupling and needs its own FGS type to survive backgrounding. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
||||
|
||||
<!-- POST_NOTIFICATIONS for Android 13+ foreground service notification -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- RECORD_AUDIO for on-device system-playback capture (AudioPlaybackCapture,
|
||||
API 29+) feeding audio-reactive lighting. Runtime "dangerous" permission,
|
||||
requested in MainActivity; capture degrades gracefully when denied.
|
||||
Playback capture runs under the existing mediaProjection FGS type, so no
|
||||
FOREGROUND_SERVICE_MICROPHONE / microphone FGS type is needed (that would
|
||||
only be required if the mic-fallback path ran inside the service). -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<!-- CAMERA for on-device webcam capture (Camera2). Runtime "dangerous"
|
||||
permission, requested in MainActivity gated on FEATURE_CAMERA_ANY so
|
||||
camera-less TV boxes never see the prompt; capture degrades gracefully
|
||||
when denied. The camera is opened ON DEMAND (only while a camera
|
||||
capture source is active). To keep capturing after the app is
|
||||
backgrounded, the service is promoted with the `camera` FGS type
|
||||
(FOREGROUND_SERVICE_CAMERA above) — but only when CAMERA is already
|
||||
granted, so a camera-less / not-yet-granted box never risks a failed
|
||||
service start. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- PACKAGE_USAGE_STATS — read the foreground app for the "Application"
|
||||
automation rule (foreground app -> activate scene) via UsageStatsManager.
|
||||
A special-access permission: it can't be granted at runtime; the user
|
||||
toggles it under Settings > Usage access (opened from MainActivity).
|
||||
tools:ignore="ProtectedPermissions" silences the build warning that this
|
||||
is a system/signature-level permission — it is honoured as a user-grantable
|
||||
special access. NO QUERY_ALL_PACKAGES is needed: matching only compares the
|
||||
foreground package NAME, and the app picker uses LauncherApps. -->
|
||||
<uses-permission
|
||||
android:name="android.permission.PACKAGE_USAGE_STATS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<!-- 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" />
|
||||
|
||||
<!-- Camera hardware — for on-device webcam capture. required=false so
|
||||
camera-less TV boxes (the common case) still install; the camera
|
||||
engine simply reports no displays on such devices. camera.any covers
|
||||
built-in (front/back) and external/USB-UVC cameras the platform
|
||||
routes through Camera2. -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera.any"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".LedGrabApp"
|
||||
android:allowBackup="false"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:banner="@drawable/banner_tv"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@style/Theme.LedGrab">
|
||||
|
||||
<!-- TV launcher activity. Boots through the SplashScreen theme so
|
||||
the (sometimes multi-second) Chaquopy stdlib unpack doesn't
|
||||
show as a black screen on first launch. -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.LedGrab.Splash">
|
||||
<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.
|
||||
Declares BOTH mediaProjection AND specialUse: only one is
|
||||
active at a time but Android needs to see the union of
|
||||
possible types up-front so it doesn't kill the service when
|
||||
we promote it with a different type at runtime.
|
||||
FOREGROUND_SERVICE_TYPE_SPECIAL_USE on API 34+ requires the
|
||||
PROPERTY_SPECIAL_USE_FGS_SUBTYPE rationale below. -->
|
||||
<service
|
||||
android:name=".CaptureService"
|
||||
android:foregroundServiceType="mediaProjection|specialUse|camera"
|
||||
android:exported="false">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Root-mode screen capture for ambient LED sync. Uses /system/bin/screenrecord on rooted devices to avoid MediaProjection's persistent capture indicator overlay, which is required for the always-on ambient-lighting use case." />
|
||||
</service>
|
||||
|
||||
<!-- Notification capture — a NotificationListenerService bound by
|
||||
system_server. exported="true" is REQUIRED here (the system binds
|
||||
it cross-process) and intentionally diverges from CaptureService
|
||||
(exported="false"); access is gated by the system-held
|
||||
BIND_NOTIFICATION_LISTENER_SERVICE permission, so no new
|
||||
<uses-permission> is needed. The user grants access via
|
||||
Settings > Notification access (opened from MainActivity). -->
|
||||
<service
|
||||
android:name=".LedGrabNotificationListener"
|
||||
android:label="@string/notification_listener_label"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- 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,243 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Persists the per-install API key for the embedded FastAPI server.
|
||||
*
|
||||
* The server's auth gate ([ledgrab.api.auth]) requires a Bearer token
|
||||
* for any non-loopback request when ``auth.api_keys`` is configured.
|
||||
* Without a key, LAN clients (phone, laptop) get 401 — which is the
|
||||
* server's secure default but breaks the QR-scan workflow.
|
||||
*
|
||||
* This class generates one key per install (random 32-byte → 64-char
|
||||
* hex), persists it to SharedPreferences, and exposes it to:
|
||||
* - [PythonBridge] which sets ``LEDGRAB_AUTH__API_KEYS=android:<key>``
|
||||
* before uvicorn starts.
|
||||
* - [MainActivity] which embeds the key as a URL fragment
|
||||
* (``http://ip:port/#k=<key>``) in the QR. Fragments are never sent
|
||||
* to the server in HTTP requests, so the key doesn't appear in
|
||||
* access logs.
|
||||
*/
|
||||
class ApiKeyManager(context: Context) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
// Prefer Android-Keystore-backed EncryptedSharedPreferences for the API
|
||||
// key. If the keystore is unavailable (some OEM TV-box ROMs ship a broken
|
||||
// or absent keystore, or a key got corrupted), creation throws — fall back
|
||||
// to plain SharedPreferences so a keystore failure NEVER bricks the local
|
||||
// API key (which would 401 every LAN client).
|
||||
private val prefs: SharedPreferences
|
||||
|
||||
init {
|
||||
val (store, isEncrypted) = buildPrefs(appContext)
|
||||
prefs = store
|
||||
// Only run the plain→encrypted migration when the encrypted store is
|
||||
// actually available; on the degraded plain path there is nothing to
|
||||
// migrate INTO (and recoverLegacyKey reads the backup directly).
|
||||
if (isEncrypted) migrateLegacyKeyIfPresent()
|
||||
}
|
||||
|
||||
// Once we've materialised a key in this process, cache it so
|
||||
// subsequent reads don't hit prefs and don't risk re-checking
|
||||
// length under contention.
|
||||
@Volatile private var cached: String? = null
|
||||
private val lock = Any()
|
||||
|
||||
/** Persistent random API key, generated lazily on first access. */
|
||||
val apiKey: String
|
||||
get() = getOrCreateKey()
|
||||
|
||||
/** Force a new key. Useful if a user thinks the QR was photographed. */
|
||||
fun rotate(): String {
|
||||
synchronized(lock) {
|
||||
val next = generateKey()
|
||||
// apply() is fine for rotation — by definition the user
|
||||
// initiated this and will see the new QR; the worst case
|
||||
// on crash is they need to re-rotate.
|
||||
prefs.edit().putString(KEY_API_KEY, next).apply()
|
||||
cached = next
|
||||
Log.i(TAG, "Rotated API key")
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreateKey(): String {
|
||||
cached?.let { return it }
|
||||
synchronized(lock) {
|
||||
// Double-checked under the lock.
|
||||
cached?.let { return it }
|
||||
val existing = prefs.getString(KEY_API_KEY, null)
|
||||
if (existing != null && existing.length >= MIN_KEY_LENGTH) {
|
||||
cached = existing
|
||||
return existing
|
||||
}
|
||||
// Before minting a fresh key, fall back to any key still in the
|
||||
// legacy plain store (covers a failed/partial encrypted migration:
|
||||
// commit() can return false WITHOUT throwing, so migration may have
|
||||
// left the live key only in the legacy file). Rotating the
|
||||
// per-install key would 401 every already-paired client, so we
|
||||
// generate a brand-new key ONLY when no key exists anywhere.
|
||||
recoverLegacyKey()?.let { recovered ->
|
||||
// Best-effort persist into the encrypted store; cache regardless
|
||||
// so we still return the recovered key if the write keeps failing.
|
||||
runCatching { prefs.edit().putString(KEY_API_KEY, recovered).commit() }
|
||||
cached = recovered
|
||||
Log.i(TAG, "Recovered existing API key from legacy storage")
|
||||
return recovered
|
||||
}
|
||||
val generated = generateKey()
|
||||
// commit() (synchronous disk write) on the FIRST write so
|
||||
// the key is durable before MainActivity encodes it into a
|
||||
// QR. If the process is killed between QR display and the
|
||||
// async write landing, the user's phone would scan a key
|
||||
// the server never learned about. Subsequent rotates can
|
||||
// safely use apply().
|
||||
prefs.edit().putString(KEY_API_KEY, generated).commit()
|
||||
cached = generated
|
||||
Log.i(TAG, "Generated new API key (length=${generated.length})")
|
||||
return generated
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the backing store, preferring EncryptedSharedPreferences. Returns
|
||||
* (store, isEncrypted). Any keystore failure falls back to the plain prefs
|
||||
* file so the local API key is never lost on a broken-keystore device.
|
||||
*/
|
||||
private fun buildPrefs(context: Context): Pair<SharedPreferences, Boolean> {
|
||||
return try {
|
||||
createEncrypted(context) to true
|
||||
} catch (e: Exception) {
|
||||
// The keystore can become invalidated (OS upgrade, device restore,
|
||||
// OEM keystore bug), after which create() throws on EVERY launch and
|
||||
// the corrupt encrypted file is never cleaned up — degrading to plain
|
||||
// prefs forever and (because the live key was only in the encrypted
|
||||
// store) rotating the per-install key on the next mint, 401-ing every
|
||||
// paired client. Self-heal once: delete the corrupt store + master key
|
||||
// alias and retry create() before degrading.
|
||||
Log.w(TAG, "EncryptedSharedPreferences unavailable, attempting one-time reset: ${e.message}")
|
||||
runCatching {
|
||||
context.deleteSharedPreferences(ENCRYPTED_PREFS_NAME)
|
||||
runCatching {
|
||||
val ks = java.security.KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
if (ks.containsAlias(MasterKey.DEFAULT_MASTER_KEY_ALIAS)) {
|
||||
ks.deleteEntry(MasterKey.DEFAULT_MASTER_KEY_ALIAS)
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Master-key alias cleanup failed: ${it.message}") }
|
||||
createEncrypted(context) to true
|
||||
}.getOrElse {
|
||||
// Still failing after reset — degrade to plain prefs rather than
|
||||
// crashing. Worst case the key is stored unencrypted on a
|
||||
// single-user TV box, which is the pre-existing behaviour.
|
||||
Log.w(TAG, "EncryptedSharedPreferences still unavailable after reset, using plain prefs: ${it.message}")
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) to false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createEncrypted(context: Context): SharedPreferences {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
ENCRYPTED_PREFS_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration: if a key exists in the legacy plain-text prefs file
|
||||
* (from before encrypted storage), copy it into the encrypted store and
|
||||
* remove the plain copy. Preserves the existing key so already-scanned QR
|
||||
* clients keep working — generating a fresh key here would silently 401
|
||||
* every LAN client (see the Data Migration Policy in CLAUDE.md).
|
||||
*/
|
||||
private fun migrateLegacyKeyIfPresent() {
|
||||
// Don't migrate if the encrypted store already holds a key.
|
||||
if (!prefs.getString(KEY_API_KEY, null).isNullOrEmpty()) return
|
||||
runCatching {
|
||||
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val legacyKey = legacy.getString(KEY_API_KEY, null)
|
||||
if (legacyKey != null && legacyKey.length >= MIN_KEY_LENGTH) {
|
||||
// commit() returns false on write failure WITHOUT throwing, so the
|
||||
// runCatching wrapper alone does NOT protect this path. Verify the
|
||||
// encrypted store both committed AND reads back the identical value
|
||||
// before touching the legacy copy — otherwise a silent write
|
||||
// failure could delete the only surviving copy of the key and
|
||||
// rotate it on next launch (401s every paired client — the exact
|
||||
// silent-data-loss the Data Migration Policy forbids).
|
||||
val ok = prefs.edit().putString(KEY_API_KEY, legacyKey).commit()
|
||||
if (ok && prefs.getString(KEY_API_KEY, null) == legacyKey) {
|
||||
// Keep the value as a .migrated backup (don't hard-delete) per
|
||||
// the migration policy; remove only the live legacy key so the
|
||||
// plaintext copy no longer answers reads.
|
||||
legacy.edit()
|
||||
.putString(KEY_API_KEY_MIGRATED, legacyKey)
|
||||
.remove(KEY_API_KEY)
|
||||
.apply()
|
||||
Log.i(TAG, "Migrated API key from plain to encrypted storage")
|
||||
} else {
|
||||
// Leave the legacy key untouched; getOrCreateKey() will recover
|
||||
// it via recoverLegacyKey() rather than minting a fresh one.
|
||||
Log.w(TAG, "Encrypted key write unverified — keeping legacy key, not migrating")
|
||||
}
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Legacy API key migration failed: ${it.message}") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover a still-present key from the legacy plain store — either the live
|
||||
* key (failed/never-run migration) or the `.migrated` backup. Returns null
|
||||
* only when no valid key survives.
|
||||
*
|
||||
* This MUST run on the degraded plain-prefs path too (not just the encrypted
|
||||
* path): after a successful migration the live key is moved to the
|
||||
* `.migrated` backup in this same plain file, so when the keystore later
|
||||
* fails and we degrade to plain prefs, the backup is the only surviving
|
||||
* copy. Returning null here (the previous `if (!encrypted) return null`
|
||||
* guard) would mint a fresh key and rotate the per-install key, 401-ing every
|
||||
* paired client.
|
||||
*/
|
||||
private fun recoverLegacyKey(): String? {
|
||||
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val candidate = legacy.getString(KEY_API_KEY, null)
|
||||
?: legacy.getString(KEY_API_KEY_MIGRATED, null)
|
||||
return candidate?.takeIf { it.length >= MIN_KEY_LENGTH }
|
||||
}
|
||||
|
||||
private fun generateKey(): String {
|
||||
val bytes = ByteArray(KEY_BYTES)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
// Hex-encode so the key survives copy/paste, URL fragments, env
|
||||
// vars, and YAML config without escaping concerns. Mask to 0xff
|
||||
// first — Kotlin's Byte is signed, and `%02x` on a negative
|
||||
// Byte sign-extends to an 8-char hex string ("ffffffff" instead
|
||||
// of "ff"), which would produce an invalid key.
|
||||
return bytes.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ApiKeyManager"
|
||||
private const val PREFS_NAME = "ledgrab_auth"
|
||||
private const val ENCRYPTED_PREFS_NAME = "ledgrab_auth_enc"
|
||||
private const val KEY_API_KEY = "api_key"
|
||||
// Backup of a migrated legacy key, kept in the plain store per the
|
||||
// Data Migration Policy (never hard-delete user data on rename/move).
|
||||
private const val KEY_API_KEY_MIGRATED = "api_key_migrated"
|
||||
private const val KEY_BYTES = 32
|
||||
private const val MIN_KEY_LENGTH = 32
|
||||
|
||||
/** Label used as the LEDGRAB_AUTH__API_KEYS map key. */
|
||||
const val LABEL = "android"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioPlaybackCaptureConfiguration
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.media.projection.MediaProjection
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* Captures audio with [AudioRecord] and pushes interleaved float32 PCM to
|
||||
* the LedGrab Python server via [PythonBridge], where the
|
||||
* `android_audio_engine` feeds it into the unchanged audio-analysis
|
||||
* pipeline.
|
||||
*
|
||||
* Two sources:
|
||||
* - [start] — system playback capture via `AudioPlaybackCapture` (API 29+),
|
||||
* reusing the same [MediaProjection] token the app already holds for
|
||||
* screen capture. This is the primary path on the consent flow.
|
||||
* - [startMic] — microphone fallback (`AudioSource.MIC`) for paths with no
|
||||
* MediaProjection (root mode) or API < 29.
|
||||
*
|
||||
* Mirrors [ScreenCapture]'s shape: a dedicated capture thread, a single
|
||||
* reusable cross-JNI buffer (no per-block allocation → no GC churn on
|
||||
* low-end TV boxes), and graceful teardown in [stop].
|
||||
*
|
||||
* The capture format is negotiated by [AudioRecord]; the **actual**
|
||||
* channel count and sample rate are read back and forwarded to
|
||||
* `configureAudio` so the Python analyzer's interleaving matches the bytes
|
||||
* we push (e.g. a stereo request that the device satisfies as mono).
|
||||
*/
|
||||
class AudioCapture(
|
||||
private val projection: MediaProjection?,
|
||||
private val bridge: PythonBridge,
|
||||
private val sampleRate: Int = 48000,
|
||||
private val channels: Int = 2,
|
||||
private val chunkFrames: Int = 1024,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "AudioCapture"
|
||||
private const val BYTES_PER_FLOAT = 4
|
||||
}
|
||||
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var captureThread: Thread? = null
|
||||
@Volatile private var running = false
|
||||
|
||||
/**
|
||||
* Start system playback capture (API 29+). Requires the app to hold
|
||||
* RECORD_AUDIO and a valid [projection]. Returns true if capture began.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start(): Boolean {
|
||||
if (running) return true
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
Log.i(TAG, "Playback capture needs API 29+; skipping (have ${Build.VERSION.SDK_INT})")
|
||||
return false
|
||||
}
|
||||
val proj = projection
|
||||
if (proj == null) {
|
||||
Log.i(TAG, "No MediaProjection; playback capture unavailable")
|
||||
return false
|
||||
}
|
||||
|
||||
val config = AudioPlaybackCaptureConfiguration.Builder(proj)
|
||||
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.addMatchingUsage(AudioAttributes.USAGE_GAME)
|
||||
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
|
||||
.build()
|
||||
|
||||
val record = try {
|
||||
AudioRecord.Builder()
|
||||
.setAudioFormat(audioFormat())
|
||||
.setBufferSizeInBytes(bufferBytes())
|
||||
.setAudioPlaybackCaptureConfig(config)
|
||||
.build()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to build playback AudioRecord: ${e.message}")
|
||||
return false
|
||||
}
|
||||
return begin(record, "playback")
|
||||
}
|
||||
|
||||
/**
|
||||
* Start microphone capture (fallback). Works on API 24+ and needs no
|
||||
* MediaProjection. Requires RECORD_AUDIO. Returns true if capture began.
|
||||
*
|
||||
* ⚠️ SECURITY/POLICY: currently UNWIRED (no caller). Microphone capture is
|
||||
* a materially different posture than playback capture — it records real
|
||||
* room audio (bystander voices). Before wiring this into [CaptureService]:
|
||||
* - add FOREGROUND_SERVICE_MICROPHONE permission + the `microphone` FGS
|
||||
* type (on API 34+ the service is killed without it), and
|
||||
* - add the Play Store privacy disclosure for microphone use,
|
||||
* - re-trigger a security review.
|
||||
* Do NOT call this from inside the foreground service without the above.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
fun startMic(): Boolean {
|
||||
if (running) return true
|
||||
val record = try {
|
||||
AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(audioFormat())
|
||||
.setBufferSizeInBytes(bufferBytes())
|
||||
.build()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to build mic AudioRecord: ${e.message}")
|
||||
return false
|
||||
}
|
||||
return begin(record, "mic")
|
||||
}
|
||||
|
||||
/** Stop capturing and release all resources. Idempotent. */
|
||||
fun stop() {
|
||||
running = false
|
||||
// AudioRecord.stop() unblocks a pending READ_BLOCKING read within
|
||||
// milliseconds, so the loop sees running=false and returns well inside
|
||||
// the 500ms join window — release() below won't race a live read.
|
||||
// (Mirrors ScreenCapture's bounded join.)
|
||||
runCatching { audioRecord?.stop() }
|
||||
captureThread?.let { runCatching { it.join(500) } }
|
||||
captureThread = null
|
||||
runCatching { audioRecord?.release() }
|
||||
audioRecord = null
|
||||
runCatching { bridge.shutdownAudio() }
|
||||
Log.i(TAG, "Audio capture stopped")
|
||||
}
|
||||
|
||||
// ── internals ──────────────────────────────────────────────────────
|
||||
|
||||
private fun begin(record: AudioRecord, mode: String): Boolean {
|
||||
if (record.state != AudioRecord.STATE_INITIALIZED) {
|
||||
Log.e(TAG, "AudioRecord ($mode) failed to initialize")
|
||||
runCatching { record.release() }
|
||||
return false
|
||||
}
|
||||
val actualChannels = record.channelCount.coerceAtLeast(1)
|
||||
val actualRate = record.sampleRate
|
||||
|
||||
// Confirm recording actually started before reporting success —
|
||||
// startRecording() can throw (exclusive-capture contention) or
|
||||
// leave the record in a non-recording state, in which case read()
|
||||
// would only ever return errors.
|
||||
val started = runCatching { record.startRecording() }.isSuccess &&
|
||||
record.recordingState == AudioRecord.RECORDSTATE_RECORDING
|
||||
if (!started) {
|
||||
Log.e(TAG, "AudioRecord ($mode) failed to start recording")
|
||||
runCatching { record.release() }
|
||||
return false
|
||||
}
|
||||
|
||||
// Recording confirmed — tell Python the real negotiated format
|
||||
// before frames flow, so the analyzer's channel/sample-rate match
|
||||
// the interleaving we push.
|
||||
bridge.configureAudio(actualRate, actualChannels, chunkFrames)
|
||||
|
||||
audioRecord = record
|
||||
running = true
|
||||
captureThread = Thread(
|
||||
{ captureLoop(record, actualChannels) },
|
||||
"LedGrab-AudioCapture",
|
||||
).also { it.start() }
|
||||
Log.i(TAG, "Audio capture started ($mode, sr=$actualRate ch=$actualChannels chunk=$chunkFrames)")
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking read loop. Accumulates into fixed `chunkFrames * channels`
|
||||
* float blocks and pushes only COMPLETE blocks — [AudioRecord.read]
|
||||
* returns a variable count, so partial reads are stitched here rather
|
||||
* than handed to Python as ragged chunks (the analyzer requires
|
||||
* whole-frame, ≤ chunk-size blocks).
|
||||
*/
|
||||
private fun captureLoop(record: AudioRecord, actualChannels: Int) {
|
||||
val blockFloats = chunkFrames * actualChannels
|
||||
val floatBuf = FloatArray(blockFloats)
|
||||
// Reusable little-endian byte buffer — Python copies on push, so the
|
||||
// same backing array is safe to overwrite next block. Default
|
||||
// ByteBuffer order is BIG_ENDIAN, which would corrupt every sample;
|
||||
// LITTLE_ENDIAN matches numpy's native float32 on all Android ABIs.
|
||||
val byteBuf = ByteArray(blockFloats * BYTES_PER_FLOAT)
|
||||
val floatView = ByteBuffer.wrap(byteBuf).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
||||
|
||||
var filled = 0
|
||||
while (running) {
|
||||
val n = record.read(floatBuf, filled, blockFloats - filled, AudioRecord.READ_BLOCKING)
|
||||
if (n < 0) {
|
||||
if (running) {
|
||||
// A negative read (e.g. ERROR_DEAD_OBJECT after an audio-route
|
||||
// change, ERROR_INVALID_OPERATION) means this AudioRecord is
|
||||
// finished. Deactivate the Python engine so is_available() stops
|
||||
// advertising a dead stream and the audio-reactive consumer isn't
|
||||
// left polling an empty queue forever. We're on the capture thread,
|
||||
// so we can't call stop() (it would self-join) — just flip running
|
||||
// and shut the engine down; onDestroy's stop() releases the record.
|
||||
Log.w(TAG, "AudioRecord.read error: $n — stopping audio capture")
|
||||
running = false
|
||||
runCatching { bridge.shutdownAudio() }
|
||||
}
|
||||
break
|
||||
}
|
||||
filled += n
|
||||
if (filled < blockFloats) continue
|
||||
|
||||
floatView.clear()
|
||||
floatView.put(floatBuf, 0, blockFloats)
|
||||
bridge.pushAudio(byteBuf)
|
||||
filled = 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun channelMask(): Int =
|
||||
if (channels >= 2) AudioFormat.CHANNEL_IN_STEREO else AudioFormat.CHANNEL_IN_MONO
|
||||
|
||||
private fun audioFormat(): AudioFormat =
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(channelMask())
|
||||
.build()
|
||||
|
||||
private fun bufferBytes(): Int {
|
||||
val minBuf = AudioRecord.getMinBufferSize(sampleRate, channelMask(), AudioFormat.ENCODING_PCM_FLOAT)
|
||||
// A few blocks of headroom so a slow consumer doesn't overrun the
|
||||
// hardware buffer between reads.
|
||||
val want = chunkFrames * channels * BYTES_PER_FLOAT * 4
|
||||
return if (minBuf > 0) maxOf(minBuf, want) else want
|
||||
}
|
||||
}
|
||||
@@ -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,319 @@
|
||||
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 {
|
||||
// startScan runs on the BLE handler thread; a denied
|
||||
// BLUETOOTH_SCAN throws SecurityException there, which would
|
||||
// crash the whole process (an uncaught exception on a handler
|
||||
// thread is fatal). Catch it inside the posted body and report.
|
||||
bleHandler.post {
|
||||
try {
|
||||
scanner.startScan(callback)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "BLUETOOTH_SCAN permission denied — scan skipped", e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "BLE startScan failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
Thread.sleep(timeoutMs)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
} finally {
|
||||
bleHandler.post {
|
||||
try {
|
||||
scanner.stopScan(callback)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "BLUETOOTH_SCAN permission denied — stopScan skipped", e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "BLE stopScan failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
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")
|
||||
// Runs on the BLE handler thread; a denied
|
||||
// BLUETOOTH_CONNECT throws SecurityException here, which
|
||||
// would crash the process. Catch and fail the connect.
|
||||
try {
|
||||
gatt.discoverServices()
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "BLUETOOTH_CONNECT denied during discoverServices", e)
|
||||
readyDeferred.complete(false)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "discoverServices failed: ${e.message}")
|
||||
readyDeferred.complete(false)
|
||||
}
|
||||
}
|
||||
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,411 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.ImageFormat
|
||||
import android.hardware.camera2.CameraCaptureSession
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraDevice
|
||||
import android.hardware.camera2.CameraManager
|
||||
import android.media.Image
|
||||
import android.media.ImageReader
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.util.Size
|
||||
import android.view.Surface
|
||||
import com.chaquo.python.PyObject
|
||||
import com.chaquo.python.Python
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Android camera bridge exposed to the Python server via Chaquopy.
|
||||
*
|
||||
* Wraps the Camera2 API into synchronous, blocking calls that can be
|
||||
* invoked from a Python thread (Chaquopy proxy threads are real OS
|
||||
* threads). The physical camera is opened **on demand** — Python's
|
||||
* `android_camera_engine` calls [startCamera] when a capture stream
|
||||
* initializes and [stopCamera] when it cleans up, so the camera-in-use
|
||||
* indicator and battery cost are limited to actual use.
|
||||
*
|
||||
* Each captured frame is converted YUV_420_888 → RGB and pushed to the
|
||||
* Python engine's `push_frame`, mirroring how [ScreenCapture] feeds
|
||||
* `mediaprojection_engine`. Camera2 callbacks run on a private
|
||||
* [HandlerThread] so they never touch the main looper.
|
||||
*
|
||||
* Python callers access the singleton via
|
||||
* `jclass("com.ledgrab.android.CameraBridge").INSTANCE` — see
|
||||
* `server/src/ledgrab/core/capture_engines/android_camera_engine.py`.
|
||||
*/
|
||||
object CameraBridge {
|
||||
private const val TAG = "CameraBridge"
|
||||
private const val ENGINE_MODULE = "ledgrab.core.capture_engines.android_camera_engine"
|
||||
private const val OPEN_TIMEOUT_MS = 8_000L
|
||||
private const val MAX_IMAGES = 2
|
||||
private const val TARGET_FPS = 20
|
||||
// "auto" capture size — balanced for ambient LED sampling (the LED
|
||||
// pipeline downscales anyway), kept modest so the per-frame YUV→RGB
|
||||
// conversion stays cheap on low-end TV boxes.
|
||||
private const val DEFAULT_W = 1280
|
||||
private const val DEFAULT_H = 720
|
||||
private const val BYTES_PER_RGB = 3
|
||||
|
||||
@Volatile private var appContext: Context? = null
|
||||
|
||||
// Dedicated looper thread so Camera2 callbacks don't land on main.
|
||||
private val camThread = HandlerThread("LedGrab-Camera").also { it.start() }
|
||||
private val camHandler = Handler(camThread.looper)
|
||||
|
||||
// Active session state — guarded by [lock]. One camera at a time.
|
||||
private val lock = Any()
|
||||
private var cameraDevice: CameraDevice? = null
|
||||
private var captureSession: CameraCaptureSession? = null
|
||||
private var imageReader: ImageReader? = null
|
||||
@Volatile private var running = false
|
||||
private var activeIndex = -1
|
||||
|
||||
// Cached Python engine module handle for the per-frame push fast path.
|
||||
@Volatile private var engineModule: PyObject? = null
|
||||
|
||||
// Reusable conversion buffers — sized once per session (output size is
|
||||
// fixed for the session), reused to avoid per-frame GC churn on TV boxes.
|
||||
private var rgbBuffer: ByteArray? = null
|
||||
private var yBuf: ByteArray? = null
|
||||
private var uBuf: ByteArray? = null
|
||||
private var vBuf: ByteArray? = null
|
||||
|
||||
// Monotonic frame pacing (mirrors ScreenCapture's accumulator).
|
||||
private val frameIntervalNanos = 1_000_000_000L / TARGET_FPS.coerceAtLeast(1)
|
||||
private var nextFrameNanos = 0L
|
||||
|
||||
/** Called once from [LedGrabApp.onCreate] to bind the application context. */
|
||||
@JvmStatic
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate cameras as a JSON array string the Python engine parses:
|
||||
* `[{"index":0,"name":"Back camera","facing":"back","cameraId":"0"}, ...]`
|
||||
*
|
||||
* Indices are stable (positional in [CameraManager.cameraIdList]) so
|
||||
* Python's `display_index` maps 1:1 to [startCamera]'s `index`.
|
||||
* Enumeration needs no CAMERA permission. Returns `[]` on any error.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun listCameras(): String {
|
||||
val arr = JSONArray()
|
||||
val ctx = appContext
|
||||
if (ctx == null) {
|
||||
Log.w(TAG, "listCameras: context not bound (init not called)")
|
||||
return arr.toString()
|
||||
}
|
||||
try {
|
||||
val mgr = ctx.getSystemService(Context.CAMERA_SERVICE) as CameraManager
|
||||
mgr.cameraIdList.forEachIndexed { idx, id ->
|
||||
val facing = facingOf(mgr, id)
|
||||
val name = when (facing) {
|
||||
"front" -> "Front camera"
|
||||
"back" -> "Back camera"
|
||||
"external" -> "External camera $idx"
|
||||
else -> "Camera $idx"
|
||||
}
|
||||
arr.put(
|
||||
JSONObject()
|
||||
.put("index", idx)
|
||||
.put("name", name)
|
||||
.put("facing", facing)
|
||||
.put("cameraId", id),
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "listCameras failed: ${e.message}")
|
||||
}
|
||||
return arr.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Open camera [index] and start streaming RGB frames to Python.
|
||||
* Blocks until the capture session is configured (or fails/times out).
|
||||
*
|
||||
* Returns false — without throwing across the JNI boundary — when the
|
||||
* CAMERA permission is missing, the index is out of range, or the
|
||||
* device/session fails to configure. Closes any previously-open camera
|
||||
* first (one active at a time).
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
@JvmStatic
|
||||
fun startCamera(index: Int, width: Int, height: Int): Boolean {
|
||||
synchronized(lock) {
|
||||
closeLocked()
|
||||
|
||||
val ctx = appContext ?: run {
|
||||
Log.w(TAG, "startCamera: context not bound")
|
||||
return false
|
||||
}
|
||||
if (ctx.checkSelfPermission(Manifest.permission.CAMERA)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
Log.w(TAG, "startCamera: CAMERA permission not granted")
|
||||
return false
|
||||
}
|
||||
|
||||
val mgr = ctx.getSystemService(Context.CAMERA_SERVICE) as CameraManager
|
||||
val ids = try {
|
||||
mgr.cameraIdList
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "startCamera: cameraIdList failed: ${e.message}")
|
||||
return false
|
||||
}
|
||||
if (index < 0 || index >= ids.size) {
|
||||
Log.w(TAG, "startCamera: index $index out of range (${ids.size} cameras)")
|
||||
return false
|
||||
}
|
||||
val cameraId = ids[index]
|
||||
val size = chooseSize(mgr, cameraId, width, height) ?: run {
|
||||
Log.w(TAG, "startCamera: no YUV output sizes for camera $index")
|
||||
return false
|
||||
}
|
||||
|
||||
val reader = ImageReader.newInstance(
|
||||
size.width, size.height, ImageFormat.YUV_420_888, MAX_IMAGES,
|
||||
)
|
||||
// Size the conversion buffers once for this session.
|
||||
rgbBuffer = ByteArray(size.width * size.height * BYTES_PER_RGB)
|
||||
yBuf = null; uBuf = null; vBuf = null
|
||||
nextFrameNanos = SystemClock.elapsedRealtimeNanos()
|
||||
reader.setOnImageAvailableListener({ r -> onFrame(r) }, camHandler)
|
||||
|
||||
return try {
|
||||
runBlocking {
|
||||
withTimeout(OPEN_TIMEOUT_MS) {
|
||||
// Publish each resource to its field as soon as it exists so
|
||||
// closeLocked() (in the catch) can release it if a LATER step
|
||||
// throws. Assigning only after setRepeatingRequest succeeds
|
||||
// would orphan the opened CameraDevice on a createSession /
|
||||
// setRepeatingRequest failure (camera stuck on; subsequent
|
||||
// opens fail with CAMERA_IN_USE).
|
||||
imageReader = reader
|
||||
val device = openCamera(mgr, cameraId)
|
||||
cameraDevice = device
|
||||
val session = createSession(device, reader.surface)
|
||||
captureSession = session
|
||||
val request = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
|
||||
.apply { addTarget(reader.surface) }
|
||||
.build()
|
||||
session.setRepeatingRequest(request, null, camHandler)
|
||||
activeIndex = index
|
||||
running = true
|
||||
Log.i(TAG, "Camera $index opened (${size.width}x${size.height} @ ${TARGET_FPS}fps)")
|
||||
true
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "startCamera($index) failed: ${e.message}")
|
||||
// imageReader/cameraDevice/captureSession are now whatever got
|
||||
// assigned before the failure — closeLocked releases each exactly
|
||||
// once (idempotent, runCatching-wrapped).
|
||||
closeLocked()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop streaming and release the camera. Idempotent; safe if not started. */
|
||||
@JvmStatic
|
||||
fun stopCamera() {
|
||||
synchronized(lock) { closeLocked() }
|
||||
Log.i(TAG, "Camera stopped")
|
||||
}
|
||||
|
||||
// ── internals ────────────────────────────────────────────────────────
|
||||
|
||||
private fun facingOf(mgr: CameraManager, id: String): String =
|
||||
when (mgr.getCameraCharacteristics(id).get(CameraCharacteristics.LENS_FACING)) {
|
||||
CameraCharacteristics.LENS_FACING_FRONT -> "front"
|
||||
CameraCharacteristics.LENS_FACING_BACK -> "back"
|
||||
CameraCharacteristics.LENS_FACING_EXTERNAL -> "external"
|
||||
else -> "unknown"
|
||||
}
|
||||
|
||||
/** Pick the supported YUV size closest in area to the request (or the
|
||||
* balanced default for `auto`/0). */
|
||||
private fun chooseSize(mgr: CameraManager, cameraId: String, reqW: Int, reqH: Int): Size? {
|
||||
val map = mgr.getCameraCharacteristics(cameraId)
|
||||
.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) ?: return null
|
||||
val sizes = map.getOutputSizes(ImageFormat.YUV_420_888)
|
||||
if (sizes == null || sizes.isEmpty()) return null
|
||||
val targetArea = (if (reqW > 0) reqW else DEFAULT_W).toLong() *
|
||||
(if (reqH > 0) reqH else DEFAULT_H)
|
||||
return sizes.minByOrNull { kotlin.math.abs(it.width.toLong() * it.height - targetArea) }
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private suspend fun openCamera(mgr: CameraManager, cameraId: String): CameraDevice =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
mgr.openCamera(cameraId, object : CameraDevice.StateCallback() {
|
||||
override fun onOpened(device: CameraDevice) {
|
||||
if (cont.isActive) cont.resume(device) else device.close()
|
||||
}
|
||||
|
||||
override fun onDisconnected(device: CameraDevice) {
|
||||
device.close()
|
||||
if (cont.isActive) cont.resumeWithException(IllegalStateException("camera disconnected"))
|
||||
}
|
||||
|
||||
override fun onError(device: CameraDevice, error: Int) {
|
||||
device.close()
|
||||
if (cont.isActive) cont.resumeWithException(IllegalStateException("camera error $error"))
|
||||
}
|
||||
}, camHandler)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private suspend fun createSession(device: CameraDevice, surface: Surface): CameraCaptureSession =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
// createCaptureSession(List, callback, handler) is deprecated at
|
||||
// API 30 but is the correct API down to minSdk 24 (the
|
||||
// SessionConfiguration overload is API 28+).
|
||||
device.createCaptureSession(
|
||||
listOf(surface),
|
||||
object : CameraCaptureSession.StateCallback() {
|
||||
override fun onConfigured(session: CameraCaptureSession) {
|
||||
if (cont.isActive) cont.resume(session)
|
||||
}
|
||||
|
||||
override fun onConfigureFailed(session: CameraCaptureSession) {
|
||||
if (cont.isActive) cont.resumeWithException(IllegalStateException("session configure failed"))
|
||||
}
|
||||
},
|
||||
camHandler,
|
||||
)
|
||||
}
|
||||
|
||||
/** ImageReader callback — paced, converts YUV→RGB, pushes to Python. */
|
||||
private fun onFrame(reader: ImageReader) {
|
||||
if (!running) {
|
||||
runCatching { reader.acquireLatestImage()?.close() }
|
||||
return
|
||||
}
|
||||
val now = SystemClock.elapsedRealtimeNanos()
|
||||
if (now < nextFrameNanos) {
|
||||
runCatching { reader.acquireLatestImage()?.close() }
|
||||
return
|
||||
}
|
||||
val image = runCatching { reader.acquireLatestImage() }.getOrNull() ?: return
|
||||
try {
|
||||
val w = image.width
|
||||
val h = image.height
|
||||
val out = ensureRgbBuffer(w * h * BYTES_PER_RGB)
|
||||
yuv420ToRgb(image, out, w, h)
|
||||
pushFrame(out, w, h)
|
||||
nextFrameNanos += frameIntervalNanos
|
||||
if (now - nextFrameNanos > frameIntervalNanos * 4) {
|
||||
nextFrameNanos = now + frameIntervalNanos
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "frame processing error: ${e.message}")
|
||||
} finally {
|
||||
runCatching { image.close() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureRgbBuffer(size: Int): ByteArray {
|
||||
val buf = rgbBuffer
|
||||
if (buf != null && buf.size == size) return buf
|
||||
return ByteArray(size).also { rgbBuffer = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stride-aware YUV_420_888 → packed RGB (3 bytes/px) using BT.601
|
||||
* fixed-point coefficients. Handles both planar and semi-planar
|
||||
* (NV21-like, pixelStride 2) chroma layouts via the plane strides.
|
||||
*/
|
||||
private fun yuv420ToRgb(image: Image, out: ByteArray, width: Int, height: Int) {
|
||||
val planes = image.planes
|
||||
val yPlane = planes[0]
|
||||
val uPlane = planes[1]
|
||||
val vPlane = planes[2]
|
||||
|
||||
val yRowStride = yPlane.rowStride
|
||||
val yPixStride = yPlane.pixelStride
|
||||
val uRowStride = uPlane.rowStride
|
||||
val uPixStride = uPlane.pixelStride
|
||||
val vRowStride = vPlane.rowStride
|
||||
val vPixStride = vPlane.pixelStride
|
||||
|
||||
// Copy each plane to a reusable array for fast indexed access
|
||||
// (ByteBuffer absolute-get per pixel is far slower).
|
||||
val yByteBuf = yPlane.buffer
|
||||
val uByteBuf = uPlane.buffer
|
||||
val vByteBuf = vPlane.buffer
|
||||
val yArr = ensurePlane(yBuf, yByteBuf.remaining()).also { yBuf = it }
|
||||
val uArr = ensurePlane(uBuf, uByteBuf.remaining()).also { uBuf = it }
|
||||
val vArr = ensurePlane(vBuf, vByteBuf.remaining()).also { vBuf = it }
|
||||
yByteBuf.get(yArr, 0, yArr.size)
|
||||
uByteBuf.get(uArr, 0, uArr.size)
|
||||
vByteBuf.get(vArr, 0, vArr.size)
|
||||
|
||||
var o = 0
|
||||
for (row in 0 until height) {
|
||||
val yRowBase = row * yRowStride
|
||||
val uvRow = row shr 1
|
||||
val uRowBase = uvRow * uRowStride
|
||||
val vRowBase = uvRow * vRowStride
|
||||
for (col in 0 until width) {
|
||||
val y = (yArr[yRowBase + col * yPixStride].toInt() and 0xFF)
|
||||
val uvCol = col shr 1
|
||||
val u = (uArr[uRowBase + uvCol * uPixStride].toInt() and 0xFF) - 128
|
||||
val v = (vArr[vRowBase + uvCol * vPixStride].toInt() and 0xFF) - 128
|
||||
// BT.601 full-range, fixed-point (<<16).
|
||||
var r = y + ((91881 * v) shr 16)
|
||||
var g = y - ((22554 * u + 46802 * v) shr 16)
|
||||
var b = y + ((116130 * u) shr 16)
|
||||
if (r < 0) r = 0 else if (r > 255) r = 255
|
||||
if (g < 0) g = 0 else if (g > 255) g = 255
|
||||
if (b < 0) b = 0 else if (b > 255) b = 255
|
||||
out[o++] = r.toByte()
|
||||
out[o++] = g.toByte()
|
||||
out[o++] = b.toByte()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Return [cached] if it already fits [n] bytes, else a fresh array. */
|
||||
private fun ensurePlane(cached: ByteArray?, n: Int): ByteArray =
|
||||
if (cached != null && cached.size == n) cached else ByteArray(n)
|
||||
|
||||
private fun pushFrame(rgb: ByteArray, width: Int, height: Int) {
|
||||
val module = engineModule ?: runCatching {
|
||||
Python.getInstance().getModule(ENGINE_MODULE)
|
||||
}.getOrNull()?.also { engineModule = it } ?: return
|
||||
try {
|
||||
module.callAttr("push_frame", rgb, width, height)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "push_frame failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** Tear down the active session. Caller holds [lock]. */
|
||||
private fun closeLocked() {
|
||||
running = false
|
||||
activeIndex = -1
|
||||
runCatching { imageReader?.setOnImageAvailableListener(null, null) }
|
||||
runCatching { captureSession?.stopRepeating() }
|
||||
runCatching { captureSession?.close() }
|
||||
captureSession = null
|
||||
runCatching { cameraDevice?.close() }
|
||||
cameraDevice = null
|
||||
runCatching { imageReader?.close() }
|
||||
imageReader = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.Manifest
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
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 androidx.core.app.ServiceCompat
|
||||
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 or root screenrecord.
|
||||
*
|
||||
* On Android 14+ the foreground-service "type" must match the work
|
||||
* being done. We promote the service with the correct type (mediaProjection
|
||||
* for the consent path, specialUse for the root path) instead of
|
||||
* declaring a single fixed type in the manifest — the manifest now
|
||||
* declares the *union* so promotion at runtime is permitted.
|
||||
*/
|
||||
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 audioCapture: AudioCapture? = 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 {
|
||||
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
|
||||
|
||||
// CRITICAL (Android 14+): for the MediaProjection path, validate the
|
||||
// projection token BEFORE promoting to a foreground service with the
|
||||
// mediaProjection FGS type. On service recreation (system redelivery
|
||||
// or a stale relaunch) the consent token is gone — promoting first and
|
||||
// then discovering the dead token causes a spurious foreground-service
|
||||
// start + immediate stop, which on strict OEMs flickers the
|
||||
// notification or trips a stopSelf loop. Bail out cleanly here, before
|
||||
// startForeground, when the MediaProjection consent data is missing.
|
||||
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "—"
|
||||
val url = "http://$localIp:$SERVER_PORT"
|
||||
|
||||
val mediaProjectionResultData: Intent? =
|
||||
if (!useRoot) extractProjectionResultData(intent) else null
|
||||
if (!useRoot && (intent == null || mediaProjectionResultData == null)) {
|
||||
// MediaProjection mode can't recover from a redelivery —
|
||||
// the consent token in the original intent is single-use.
|
||||
//
|
||||
// We were launched via startForegroundService(), so the OS REQUIRES
|
||||
// a startForeground() within ~5s even on this immediate-stop path,
|
||||
// or it raises the fatal ForegroundServiceDidNotStartInTimeException.
|
||||
// Promote with a benign SPECIAL_USE type (NOT mediaProjection — we
|
||||
// have no valid consent token, and requesting that type without an
|
||||
// active projection is exactly what we're avoiding) just long enough
|
||||
// to satisfy the contract, then stop.
|
||||
Log.w(TAG, "MediaProjection start without a valid consent token — stopping")
|
||||
runCatching {
|
||||
val bailType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(url), bailType)
|
||||
}.onFailure { Log.w(TAG, "Bail-path startForeground failed: ${it.message}") }
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
// startForeground must be called IMMEDIATELY after the token check —
|
||||
// before any heavier work like getMediaProjection(). The service type
|
||||
// must match the work; pass it explicitly via ServiceCompat so we stay
|
||||
// compatible back to API 24. The MEDIA_PROJECTION type is only used
|
||||
// here once resultData is confirmed non-null (checked above).
|
||||
try {
|
||||
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
var t = if (useRoot) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
} else {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
||||
}
|
||||
// On-demand webcam capture opens the camera from this service.
|
||||
// To retain camera access once the app is backgrounded (the
|
||||
// always-on ambient-lighting case), API 34+ requires the camera
|
||||
// FGS type. Add it ONLY when CAMERA is already granted — promoting
|
||||
// with the camera type without the runtime permission throws and
|
||||
// would kill the whole service on the (common) camera-less or
|
||||
// not-yet-granted box. If CAMERA is granted later, it takes effect
|
||||
// on the next Start (matches the audio/permission UX).
|
||||
if (checkSelfPermission(Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
t = t or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
|
||||
}
|
||||
t
|
||||
} else {
|
||||
0
|
||||
}
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
NOTIFICATION_ID,
|
||||
buildNotification(url),
|
||||
type,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Most common cause: missing foregroundServiceType permission
|
||||
// or denied POST_NOTIFICATIONS on API 33+.
|
||||
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
|
||||
|
||||
try {
|
||||
if (useRoot) {
|
||||
startRootCapture(url)
|
||||
} else {
|
||||
// mediaProjectionResultData is guaranteed non-null here — the
|
||||
// token was validated before startForeground above.
|
||||
startMediaProjectionCapture(intent!!, mediaProjectionResultData!!, 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 apiKey(): String? =
|
||||
(application as? LedGrabApp)?.apiKeyManager?.apiKey
|
||||
|
||||
private fun startRootCapture(url: String) {
|
||||
val newBridge = PythonBridge(this).also { b ->
|
||||
b.configureRootCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
|
||||
b.startServer(SERVER_PORT, apiKey())
|
||||
}
|
||||
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.
|
||||
*
|
||||
* Synchronized so a concurrent onDestroy() either (a) sees the old
|
||||
* instance and stops it then null-out, or (b) sees the new instance
|
||||
* and stops it. There is no window where a fresh instance can be
|
||||
* orphaned with no one holding a reference to it.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun restartRootPipeline(): Boolean {
|
||||
val currentBridge = bridge ?: return false
|
||||
// Tear down the old instance first so we don't run two
|
||||
// screenrecord processes simultaneously fighting for the GPU.
|
||||
rootCapture?.let { old ->
|
||||
rootCapture = null
|
||||
runCatching { old.stop() }
|
||||
}
|
||||
|
||||
val next = RootScreenrecord(
|
||||
bridge = currentBridge,
|
||||
width = CAPTURE_WIDTH,
|
||||
height = CAPTURE_HEIGHT,
|
||||
fps = CAPTURE_FPS,
|
||||
)
|
||||
// Publish BEFORE start() — if onDestroy fires after this
|
||||
// assignment but before start() completes, the field is non-null
|
||||
// and onDestroy will stop() it properly. start() is idempotent
|
||||
// enough (running=true, then resource construction) that being
|
||||
// raced by stop() at most produces a brief partial-init that
|
||||
// the next stop() call cleans up.
|
||||
rootCapture = next
|
||||
if (!next.start()) {
|
||||
Log.e(TAG, "Root capture failed to restart")
|
||||
// start() already called stop() on itself on the failure
|
||||
// path — but null out the field so the watchdog/onDestroy
|
||||
// don't try to stop it again.
|
||||
rootCapture = null
|
||||
return false
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the single-use MediaProjection consent token from the start
|
||||
* intent, or null if the intent is missing/redelivered without it.
|
||||
* Called BEFORE startForeground so the mediaProjection FGS type is only
|
||||
* ever requested when a valid token is present (see onStartCommand).
|
||||
*/
|
||||
private fun extractProjectionResultData(intent: Intent?): Intent? {
|
||||
if (intent == null) return null
|
||||
@Suppress("DEPRECATION")
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
|
||||
} else {
|
||||
intent.getParcelableExtra(EXTRA_RESULT_DATA)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startMediaProjectionCapture(intent: Intent, resultData: Intent, url: String) {
|
||||
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
|
||||
|
||||
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 = 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, apiKey())
|
||||
}
|
||||
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() }
|
||||
|
||||
// Reuse the same projection to capture system playback audio so
|
||||
// audio-reactive lighting works on-device (API 29+, RECORD_AUDIO
|
||||
// granted). Best-effort: screen capture and the server keep running
|
||||
// if audio is unavailable. Started AFTER ScreenCapture so the
|
||||
// projection's callback is already registered.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
checkSelfPermission(Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
audioCapture = AudioCapture(projection, newBridge).also { ac ->
|
||||
if (!ac.start()) {
|
||||
Log.i(TAG, "Playback audio capture unavailable — continuing without audio")
|
||||
audioCapture = null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "RECORD_AUDIO not granted or API < 29 — audio-reactive capture disabled")
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Stop audio before the server: stop() calls bridge.shutdownAudio().
|
||||
audioCapture?.stop()
|
||||
audioCapture = 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,
|
||||
getString(R.string.notification_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = getString(R.string.notification_channel_description)
|
||||
}
|
||||
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(getString(R.string.notification_title))
|
||||
.setContentText(getString(R.string.notification_text, url))
|
||||
// ic_notification is a monochrome 24dp vector — status-bar
|
||||
// icons must be white-on-transparent or they render as a
|
||||
// gray blob on Android 5+.
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setColor(0xFF64FFDA.toInt())
|
||||
.setColorized(true)
|
||||
.setContentIntent(tapIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.app.AppOpsManager
|
||||
import android.app.usage.UsageEvents
|
||||
import android.app.usage.UsageStatsManager
|
||||
import android.content.Context
|
||||
import android.content.pm.LauncherApps
|
||||
import android.os.Build
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Foreground-app + installed-app bridge exposed to the Python server via Chaquopy.
|
||||
*
|
||||
* Backs the Android implementation of the "Application" automation rule
|
||||
* (foreground app -> activate scene). Desktop detects the foreground process via
|
||||
* Win32 ctypes in ``platform_detector.py``; Android has no such API, so this
|
||||
* bridge wraps two in-platform services into synchronous calls a Python thread
|
||||
* can invoke (Chaquopy proxy threads are real OS threads):
|
||||
*
|
||||
* - [getForegroundPackage] via [UsageStatsManager] (needs PACKAGE_USAGE_STATS,
|
||||
* a special-access permission granted from Settings — see MainActivity).
|
||||
* - [listLaunchableApps] via [LauncherApps] for the automation editor's app
|
||||
* picker (no QUERY_ALL_PACKAGES needed — getActivityList is the sanctioned
|
||||
* launchable-app enumeration API).
|
||||
* - [hasUsageAccess] so the server / UI can detect the missing grant.
|
||||
*
|
||||
* Detection only ever string-compares the foreground *package name*, so no label
|
||||
* resolution / package visibility is required at match time.
|
||||
*
|
||||
* Python callers access the singleton via
|
||||
* `jclass("com.ledgrab.android.ForegroundAppBridge").INSTANCE` — see
|
||||
* `server/src/ledgrab/core/automations/platform_detector.py`.
|
||||
*/
|
||||
object ForegroundAppBridge {
|
||||
private const val TAG = "ForegroundAppBridge"
|
||||
|
||||
// Trailing window for queryEvents. queryEvents reports discrete foreground
|
||||
// transitions (not "current app"), and events can lag a few seconds, so we
|
||||
// look back far enough to reliably catch the latest MOVE_TO_FOREGROUND while
|
||||
// staying recent enough not to report a stale app on the ~1s automation tick.
|
||||
private const val WINDOW_MS = 10_000L
|
||||
|
||||
@Volatile private var appContext: Context? = null
|
||||
|
||||
/** Called once from [LedGrabApp.onCreate] to bind the application context. */
|
||||
@JvmStatic
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Package name of the most recently foregrounded app, or null when none is
|
||||
* found in the trailing window, Usage Access is not granted, or on any error.
|
||||
* Never throws across the JNI boundary.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getForegroundPackage(): String? {
|
||||
val ctx = appContext ?: run {
|
||||
Log.w(TAG, "getForegroundPackage: context not bound (init not called)")
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
val usm = ctx.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager
|
||||
?: return null
|
||||
val end = System.currentTimeMillis()
|
||||
val events = usm.queryEvents(end - WINDOW_MS, end)
|
||||
val event = UsageEvents.Event()
|
||||
var latestPkg: String? = null
|
||||
var latestTs = Long.MIN_VALUE
|
||||
while (events.hasNextEvent()) {
|
||||
events.getNextEvent(event)
|
||||
// ACTIVITY_RESUMED (API 29+) shares the value of the legacy
|
||||
// MOVE_TO_FOREGROUND constant, so the single check covers both.
|
||||
// >= (not >) so that on an exact-timestamp tie the later-iterated
|
||||
// event wins — events arrive chronologically, so that is the most
|
||||
// recent foreground transition.
|
||||
if (event.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND &&
|
||||
event.timeStamp >= latestTs
|
||||
) {
|
||||
latestTs = event.timeStamp
|
||||
latestPkg = event.packageName
|
||||
}
|
||||
}
|
||||
latestPkg
|
||||
} catch (e: Exception) {
|
||||
// SecurityException when access is missing, plus any service error.
|
||||
Log.w(TAG, "getForegroundPackage failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the user has granted Usage Access (PACKAGE_USAGE_STATS) to this app. */
|
||||
@JvmStatic
|
||||
fun hasUsageAccess(): Boolean {
|
||||
val ctx = appContext ?: return false
|
||||
return try {
|
||||
val appOps = ctx.getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager
|
||||
?: return false
|
||||
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
appOps.unsafeCheckOpNoThrow(
|
||||
AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), ctx.packageName,
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
appOps.checkOpNoThrow(
|
||||
AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), ctx.packageName,
|
||||
)
|
||||
}
|
||||
mode == AppOpsManager.MODE_ALLOWED
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "hasUsageAccess failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launchable apps as a JSON array string the Python server parses:
|
||||
* `[{"package":"com.netflix.mediaclient","label":"Netflix"}, ...]`
|
||||
*
|
||||
* Uses [LauncherApps.getActivityList] (launcher + leanback launchables) —
|
||||
* no QUERY_ALL_PACKAGES. De-duplicated by package, sorted by label.
|
||||
* Returns `[]` on any error.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun listLaunchableApps(): String {
|
||||
val arr = JSONArray()
|
||||
val ctx = appContext ?: run {
|
||||
Log.w(TAG, "listLaunchableApps: context not bound (init not called)")
|
||||
return arr.toString()
|
||||
}
|
||||
try {
|
||||
val launcher = ctx.getSystemService(Context.LAUNCHER_APPS_SERVICE) as? LauncherApps
|
||||
?: return arr.toString()
|
||||
val seen = HashSet<String>()
|
||||
val items = ArrayList<Pair<String, String>>()
|
||||
for (info in launcher.getActivityList(null, Process.myUserHandle())) {
|
||||
val pkg = info.applicationInfo?.packageName ?: continue
|
||||
if (!seen.add(pkg)) continue
|
||||
val label = info.label?.toString().takeUnless { it.isNullOrBlank() } ?: pkg
|
||||
items.add(pkg to label)
|
||||
}
|
||||
items.sortBy { it.second.lowercase() }
|
||||
for ((pkg, label) in items) {
|
||||
arr.put(JSONObject().put("package", pkg).put("label", label))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "listLaunchableApps failed: ${e.message}")
|
||||
}
|
||||
return arr.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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
|
||||
|
||||
/** Lazily-initialized API-key manager (see [ApiKeyManager]). */
|
||||
val apiKeyManager: ApiKeyManager by lazy { ApiKeyManager(this) }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
installCrashLogger()
|
||||
pruneOldCrashLogs()
|
||||
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)
|
||||
// Bind application context for the camera bridge so Python can
|
||||
// enumerate cameras and open them on demand (webcam capture).
|
||||
CameraBridge.init(this)
|
||||
// Bind application context for the foreground-app bridge so Python can
|
||||
// detect the foreground app (Application automation rule) and list
|
||||
// launchable apps for the editor's picker.
|
||||
ForegroundAppBridge.init(this)
|
||||
|
||||
// Pre-warm the API key on a background thread. First-launch
|
||||
// generation does a SharedPreferences.commit() (synchronous
|
||||
// disk write — 10-50 ms on slow TV-box flash), which would
|
||||
// hit the Main thread otherwise when MainActivity / CaptureService
|
||||
// reads it. Doing it here makes subsequent reads memory-only.
|
||||
Thread({
|
||||
runCatching { apiKeyManager.apiKey }
|
||||
}, "ledgrab-apikey-warmup").apply { isDaemon = true }.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the most recent [MAX_CRASH_LOGS] crash files so a
|
||||
* long-lived install doesn't slowly fill its private storage with
|
||||
* historical traces. Cheap on every launch — listFiles is O(n)
|
||||
* but n is tiny by construction.
|
||||
*/
|
||||
private fun pruneOldCrashLogs() {
|
||||
val logs = filesDir.listFiles { f ->
|
||||
f.isFile && f.name.startsWith("crash-") && f.name.endsWith(".log")
|
||||
} ?: return
|
||||
if (logs.size <= MAX_CRASH_LOGS) return
|
||||
logs.sortedByDescending { it.lastModified() }
|
||||
.drop(MAX_CRASH_LOGS)
|
||||
.forEach { runCatching { it.delete() } }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LedGrabApp"
|
||||
private const val MAX_CRASH_LOGS = 10
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.app.Notification
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import android.util.Log
|
||||
import com.chaquo.python.Python
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.RejectedExecutionException
|
||||
|
||||
/**
|
||||
* Captures posted OS notifications and forwards the posting app's display
|
||||
* label to the Python notification pipeline, where the existing
|
||||
* `NotificationColorStripSource` fires its one-shot LED effect.
|
||||
*
|
||||
* Direction is Kotlin -> Python via the process-global Chaquopy instance
|
||||
* (NOT a per-[CaptureService] [PythonBridge]): `system_server` binds this
|
||||
* service independently of [CaptureService], so it resolves Python itself.
|
||||
* The Python receiver (`os_notification_listener.push_notification`) is a
|
||||
* no-op whenever the server/listener isn't running, so a notification
|
||||
* arriving before — or after — a capture session is safely ignored.
|
||||
*/
|
||||
class LedGrabNotificationListener : NotificationListenerService() {
|
||||
|
||||
// Serial executor: the Python receiver does a (non-concurrency-safe) history
|
||||
// disk write and may play a sound, so pushes must not overlap. Off the main
|
||||
// looper to keep the system service responsive.
|
||||
//
|
||||
// Tied to the listener-connection lifecycle (onListenerConnected /
|
||||
// onListenerDisconnected), NOT onDestroy: this is a system-rebindable
|
||||
// service, so it can be connected/disconnected multiple times across a
|
||||
// single onCreate..onDestroy span. Managing the executor here — combined
|
||||
// with the runCatching guard at the submit site — keeps a notification
|
||||
// that races teardown from triggering RejectedExecutionException on a
|
||||
// shut-down executor. @Volatile so the connect/disconnect callbacks (which
|
||||
// may run on a different thread than onNotificationPosted) publish safely.
|
||||
@Volatile private var pushExecutor: ExecutorService? = null
|
||||
|
||||
// Guards executor creation so the lazy submit-site fallback and
|
||||
// onListenerConnected can't race two executors into existence.
|
||||
private val executorLock = Any()
|
||||
|
||||
// Tracks whether the listener is currently connected. ensureExecutor() only
|
||||
// CREATES a new executor while connected — otherwise a notification racing
|
||||
// onListenerDisconnected (which nulls pushExecutor) would spin up a fresh
|
||||
// executor that nothing reaps until the next disconnect cycle (a thread leak).
|
||||
@Volatile private var connected: Boolean = false
|
||||
|
||||
// packageName -> resolved human-readable label. Matches the app_name the
|
||||
// Windows/Linux backends pass, so per-app colors/filters keep working.
|
||||
// Naturally bounded by the number of notification-posting apps (tens) and
|
||||
// cleared with the process — no eviction needed.
|
||||
private val labelCache = ConcurrentHashMap<String, String>()
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification?) {
|
||||
val notification = sbn ?: return
|
||||
|
||||
// The Python server (and thus the listener) only exists during a capture
|
||||
// session. isRunning is a coarse early-out — the authoritative gate is the
|
||||
// Python receiver's None-check — but it avoids needless JNI churn here.
|
||||
if (!CaptureService.isRunning) return
|
||||
|
||||
// Filter notifications that should never drive an effect:
|
||||
// - ongoing (media transport, downloads): not user-facing "alerts"
|
||||
// - group summaries: duplicate their child notifications
|
||||
// - our own foreground-service notification: would self-trigger
|
||||
if (notification.isOngoing) return
|
||||
if ((notification.notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0) return
|
||||
if (notification.packageName == packageName) return
|
||||
|
||||
val label = resolveAppLabel(notification.packageName)
|
||||
|
||||
// Obtain (creating if needed) the executor. onListenerConnected normally
|
||||
// creates it, but that callback is not reliably invoked on every
|
||||
// OEM/version (re)bind, and a notification can arrive before it fires —
|
||||
// lazily creating here keeps a missing/late onListenerConnected from
|
||||
// permanently disabling notification forwarding. A late submit onto an
|
||||
// executor that onListenerDisconnected is shutting down throws
|
||||
// RejectedExecutionException — guard with runCatching so a notification
|
||||
// racing teardown can never crash this system-bound service.
|
||||
val executor = ensureExecutor() ?: run {
|
||||
Log.d(TAG, "no executor (listener disconnected) — skipping push")
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
executor.execute {
|
||||
try {
|
||||
Python.getInstance()
|
||||
.getModule(PY_MODULE)
|
||||
.callAttr("push_notification", label)
|
||||
} catch (t: Throwable) {
|
||||
// Never crash a system-bound service. Python.getInstance() throws
|
||||
// IllegalStateException if Python.start() hasn't run (e.g. the
|
||||
// service was bound at boot before the app process initialized).
|
||||
// Log at debug — the label is potentially sensitive on a shared TV.
|
||||
Log.d(TAG, "push_notification failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
}.onFailure { e ->
|
||||
if (e is RejectedExecutionException) {
|
||||
Log.d(TAG, "push rejected — listener disconnecting")
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve (and cache) a package's human-readable label; fall back to the package name. */
|
||||
private fun resolveAppLabel(pkg: String): String {
|
||||
labelCache[pkg]?.let { return it }
|
||||
// Only cache SUCCESSFUL resolutions. Caching the package-name fallback
|
||||
// would permanently pin a wrong label if the PackageManager lookup
|
||||
// failed transiently (e.g. the app was mid-install / still updating).
|
||||
val resolved = runCatching {
|
||||
val info = packageManager.getApplicationInfo(pkg, 0)
|
||||
packageManager.getApplicationLabel(info).toString()
|
||||
}.getOrNull()
|
||||
if (resolved != null) {
|
||||
labelCache[pkg] = resolved
|
||||
return resolved
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the push executor, creating it under [executorLock] if absent AND
|
||||
* the listener is connected. Returns null when disconnected so a notification
|
||||
* racing teardown neither submits onto a shutting-down executor nor spins up
|
||||
* a stray one. Safe against a concurrent onListenerConnected/onNotificationPosted
|
||||
* race (single executor) and against a missing onListenerConnected callback.
|
||||
*/
|
||||
private fun ensureExecutor(): ExecutorService? {
|
||||
pushExecutor?.let { return it }
|
||||
synchronized(executorLock) {
|
||||
if (!connected) return null
|
||||
return pushExecutor ?: Executors.newSingleThreadExecutor().also { pushExecutor = it }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListenerConnected() {
|
||||
Log.i(TAG, "Notification listener connected")
|
||||
// Spin up the push executor on connect. The system can disconnect and
|
||||
// later reconnect this service without destroying it, so own the
|
||||
// executor here rather than in onCreate/onDestroy. onNotificationPosted
|
||||
// also lazily creates it (via ensureExecutor) in case this callback is
|
||||
// late or skipped on some ROMs.
|
||||
connected = true
|
||||
ensureExecutor()
|
||||
}
|
||||
|
||||
override fun onListenerDisconnected() {
|
||||
Log.i(TAG, "Notification listener disconnected")
|
||||
// Mark disconnected BEFORE nulling the executor so a racing ensureExecutor
|
||||
// sees !connected and skips creating a replacement. Tear the executor
|
||||
// down; a fresh one is created on the next onListenerConnected.
|
||||
connected = false
|
||||
pushExecutor?.let { exec ->
|
||||
pushExecutor = null
|
||||
exec.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
// Defensive: onListenerDisconnected normally clears this first, but
|
||||
// shut down here too in case onDestroy fires without a prior disconnect.
|
||||
connected = false
|
||||
pushExecutor?.shutdown()
|
||||
pushExecutor = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LedGrabNotifListener"
|
||||
private const val PY_MODULE = "ledgrab.core.processing.os_notification_listener"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.Manifest
|
||||
import android.animation.ObjectAnimator
|
||||
import android.animation.ValueAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
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.view.ViewStub
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.widget.Button
|
||||
import android.widget.CheckBox
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
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.runInterruptible
|
||||
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 const val REQUEST_RECORD_AUDIO = 1003
|
||||
private const val REQUEST_CAMERA = 1004
|
||||
private const val REQUEST_BLUETOOTH = 1005
|
||||
private const val QR_SIZE_PX = 560
|
||||
private const val NOTIF_PREFS = "ledgrab_notif"
|
||||
private const val KEY_NOTIF_ACCESS_PROMPTED = "notif_access_prompted"
|
||||
}
|
||||
|
||||
// Stopped-state views (always inflated).
|
||||
private lateinit var stoppedPanel: View
|
||||
private lateinit var statusText: TextView
|
||||
private lateinit var toggleButton: Button
|
||||
private lateinit var versionText: TextView
|
||||
private lateinit var autostartCheck: CheckBox
|
||||
private lateinit var autostartPrefs: AutostartPrefs
|
||||
private lateinit var grantNotificationButton: Button
|
||||
private lateinit var grantUsageAccessButton: Button
|
||||
|
||||
// Running-state views (lazy-inflated via ViewStub).
|
||||
private lateinit var runningPanelStub: ViewStub
|
||||
private var runningPanel: View? = null
|
||||
private var urlText: TextView? = null
|
||||
private var qrImage: ImageView? = null
|
||||
private var stopButtonRunning: Button? = null
|
||||
private var statusDot: View? = null
|
||||
private var statusDotAnimator: ObjectAnimator? = null
|
||||
|
||||
// Cache of the most recently rendered QR (and the URL it encodes).
|
||||
// updateUI() runs on every onResume (HDMI-CEC wakes, app switches,
|
||||
// overlay dismissal, etc.). Rebuilding the 560×560 bitmap each time
|
||||
// is wasteful — usually the IP and key are unchanged. Cache and
|
||||
// short-circuit when the URL matches.
|
||||
private var cachedQrUrl: String? = null
|
||||
private var cachedQrBitmap: Bitmap? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Install the splash screen BEFORE super.onCreate so the system
|
||||
// keeps it on screen until our first frame is ready. This hides
|
||||
// the Chaquopy stdlib unpack delay on cold first launch.
|
||||
val splashScreen = installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Surface fatal Python init errors instead of crashing.
|
||||
val initError = (application as? LedGrabApp)?.initError
|
||||
if (initError != null) {
|
||||
// Tell the splash screen to dismiss immediately — we're
|
||||
// about to render an error screen, not the main UI.
|
||||
splashScreen.setKeepOnScreenCondition { false }
|
||||
showFatalErrorScreen(initError)
|
||||
return
|
||||
}
|
||||
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
stoppedPanel = findViewById(R.id.stopped_panel)
|
||||
runningPanelStub = findViewById(R.id.running_panel_stub)
|
||||
statusText = findViewById(R.id.status_text)
|
||||
toggleButton = findViewById(R.id.toggle_button)
|
||||
versionText = findViewById(R.id.version_text)
|
||||
autostartCheck = findViewById(R.id.autostart_check)
|
||||
grantNotificationButton = findViewById(R.id.grant_notification_button)
|
||||
grantUsageAccessButton = findViewById(R.id.grant_usage_access_button)
|
||||
|
||||
val versionName = packageManager.getPackageInfo(packageName, 0).versionName
|
||||
versionText.text = getString(R.string.version_prefix, versionName ?: "?")
|
||||
|
||||
autostartPrefs = AutostartPrefs(this)
|
||||
// Autostart only takes effect on rooted devices. Hide the
|
||||
// checkbox entirely on unrooted hardware instead of showing a
|
||||
// disabled-but-visible control, which reads as broken UI from
|
||||
// across the room.
|
||||
if (Root.looksRooted()) {
|
||||
autostartCheck.visibility = View.VISIBLE
|
||||
autostartCheck.isChecked = autostartPrefs.isEnabled
|
||||
autostartCheck.setOnCheckedChangeListener { _, isChecked ->
|
||||
autostartPrefs.isEnabled = isChecked
|
||||
if (isChecked) ensureIgnoringBatteryOptimizations()
|
||||
}
|
||||
} else {
|
||||
autostartCheck.visibility = View.GONE
|
||||
}
|
||||
|
||||
grantNotificationButton.setOnClickListener { openNotificationListenerSettings() }
|
||||
grantUsageAccessButton.setOnClickListener { openUsageAccessSettings() }
|
||||
toggleButton.setOnClickListener { startCapture() }
|
||||
|
||||
updateStoppedPermissionButtons()
|
||||
updateUI()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopStatusDotPulse()
|
||||
uiScope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
// ObjectAnimator retains a hard reference to the dot View. On
|
||||
// backgrounded TV apps onDestroy may never fire, so cancel here
|
||||
// to avoid leaking the entire view hierarchy through an
|
||||
// INFINITE-repeat animator.
|
||||
stopStatusDotPulse()
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (!::stoppedPanel.isInitialized) return
|
||||
// Restart the pulse if we returned to the foreground while the
|
||||
// service is still running. The running panel's view may have been
|
||||
// recreated; ensureRunningPanelInflated already keys off the field
|
||||
// reference. When stopped, refresh the notification-access button —
|
||||
// the user may have just granted/revoked access in Settings.
|
||||
if (CaptureService.isRunning) {
|
||||
updateUI()
|
||||
} else {
|
||||
updateStoppedPermissionButtons()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private fun startCapture() {
|
||||
// `su -c id` can block for seconds while Magisk shows its grant
|
||||
// dialog; running it on the Main thread caused ANRs. Render an
|
||||
// explicit "starting" state so the button doesn't look frozen.
|
||||
val originalText = toggleButton.text
|
||||
toggleButton.isEnabled = false
|
||||
toggleButton.text = getString(R.string.btn_starting)
|
||||
statusText.text = getString(R.string.status_checking_root)
|
||||
uiScope.launch(Dispatchers.IO) {
|
||||
// runInterruptible so a config change (rotation) during the
|
||||
// up-to-10s `su` probe cancels the coroutine AND interrupts the
|
||||
// blocking probe thread — Root.requestGrant honours the interrupt,
|
||||
// destroys the su child, and rethrows, so we don't leak the
|
||||
// process + drain thread. Without this, IO-dispatcher cancellation
|
||||
// would not interrupt the blocking waitFor().
|
||||
val rooted = runInterruptible { Root.requestGrant() }
|
||||
withContext(Dispatchers.Main) {
|
||||
toggleButton.isEnabled = true
|
||||
toggleButton.text = originalText
|
||||
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()
|
||||
ensureNotificationListenerAccess()
|
||||
ensureCameraPermission()
|
||||
ensureBluetoothPermissions()
|
||||
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 = getString(R.string.status_permission_denied)
|
||||
Log.w(TAG, "MediaProjection permission denied")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCaptureService(resultCode: Int, resultData: Intent) {
|
||||
ensureNotificationPermission()
|
||||
ensureNotificationListenerAccess()
|
||||
ensureAudioPermission()
|
||||
ensureCameraPermission()
|
||||
ensureBluetoothPermissions()
|
||||
val intent = CaptureService.createIntent(this, resultCode, resultData)
|
||||
ContextCompat.startForegroundService(this, intent)
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private fun stopCaptureService() {
|
||||
stopService(Intent(this, CaptureService::class.java))
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private fun ensureRunningPanelInflated(): View {
|
||||
runningPanel?.let { return it }
|
||||
val view = runningPanelStub.inflate()
|
||||
urlText = view.findViewById(R.id.url_text)
|
||||
qrImage = view.findViewById(R.id.qr_image)
|
||||
stopButtonRunning = view.findViewById(R.id.stop_button_running)
|
||||
statusDot = view.findViewById(R.id.status_dot)
|
||||
stopButtonRunning?.setOnClickListener { stopCaptureService() }
|
||||
runningPanel = view
|
||||
return view
|
||||
}
|
||||
|
||||
private fun updateUI() {
|
||||
// Fatal-init-error path took over setContentView and the
|
||||
// lateinit view fields are unassigned. Guard so any future
|
||||
// caller (Resume, broadcast receiver, etc.) doesn't NPE.
|
||||
if (!::stoppedPanel.isInitialized) return
|
||||
if (CaptureService.isRunning) {
|
||||
val running = ensureRunningPanelInflated()
|
||||
val localIp = NetworkUtils.getLocalIpAddress(this)
|
||||
if (localIp == null) {
|
||||
// No network — show the no-network state inside the
|
||||
// stopped panel and keep capture stopped. The service
|
||||
// is alive (capture works on loopback) but the URL/QR
|
||||
// are useless without a routable address.
|
||||
statusText.text = getString(R.string.status_no_network)
|
||||
stoppedPanel.visibility = View.VISIBLE
|
||||
versionText.visibility = View.VISIBLE
|
||||
running.visibility = View.GONE
|
||||
toggleButton.requestFocus()
|
||||
return
|
||||
}
|
||||
|
||||
val displayUrl = "http://$localIp:$SERVER_PORT"
|
||||
val qrUrl = qrUrlFor(displayUrl)
|
||||
|
||||
urlText?.text = displayUrl
|
||||
val cachedForUrl = cachedQrBitmap?.takeIf { cachedQrUrl == qrUrl }
|
||||
if (cachedForUrl != null) {
|
||||
qrImage?.setImageBitmap(cachedForUrl)
|
||||
} else {
|
||||
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(qrUrl)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (CaptureService.isRunning && urlText?.text == displayUrl) {
|
||||
cachedQrUrl = qrUrl
|
||||
cachedQrBitmap = bitmap
|
||||
qrImage?.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stoppedPanel.visibility = View.GONE
|
||||
versionText.visibility = View.GONE
|
||||
running.visibility = View.VISIBLE
|
||||
stopButtonRunning?.requestFocus()
|
||||
startStatusDotPulse()
|
||||
} else {
|
||||
stopStatusDotPulse()
|
||||
urlText?.text = ""
|
||||
qrImage?.setImageBitmap(null)
|
||||
// Drop the cached bitmap so a Start → IP change → Start
|
||||
// sequence rebuilds the QR for the new address.
|
||||
cachedQrUrl = null
|
||||
cachedQrBitmap = null
|
||||
|
||||
runningPanel?.visibility = View.GONE
|
||||
stoppedPanel.visibility = View.VISIBLE
|
||||
versionText.visibility = View.VISIBLE
|
||||
toggleButton.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the URL we encode into the QR. Embeds the API key as a
|
||||
* URL fragment (``#k=<token>``) so:
|
||||
* - The token never appears in HTTP requests (fragments aren't
|
||||
* sent over the wire) — no access-log leak.
|
||||
* - The frontend can read [location.hash] on first visit and
|
||||
* persist the key to localStorage (see static/js/app.ts).
|
||||
* - The visible URL chip stays short and human-readable.
|
||||
*
|
||||
* The chip text in [updateUI] intentionally uses the *base* URL
|
||||
* (without the fragment) so a human reading the URL out loud
|
||||
* doesn't have to dictate 64 hex chars; only the QR carries the
|
||||
* key. Do not collapse these into a single string — that would
|
||||
* leak the key onto the screen.
|
||||
*/
|
||||
private fun qrUrlFor(base: String): String {
|
||||
val key = (application as? LedGrabApp)?.apiKeyManager?.apiKey
|
||||
return if (key.isNullOrBlank()) base else "$base/#k=$key"
|
||||
}
|
||||
|
||||
private fun startStatusDotPulse() {
|
||||
val dot = statusDot ?: return
|
||||
if (statusDotAnimator?.isStarted == true) return
|
||||
val animator = ObjectAnimator.ofFloat(dot, "alpha", 1f, 0.35f).apply {
|
||||
duration = 900
|
||||
repeatCount = ValueAnimator.INFINITE
|
||||
repeatMode = ValueAnimator.REVERSE
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
}
|
||||
animator.start()
|
||||
statusDotAnimator = animator
|
||||
}
|
||||
|
||||
private fun stopStatusDotPulse() {
|
||||
statusDotAnimator?.cancel()
|
||||
statusDotAnimator = null
|
||||
statusDot?.alpha = 1f
|
||||
}
|
||||
|
||||
private fun generateQrCode(text: String): Bitmap {
|
||||
val size = QR_SIZE_PX
|
||||
val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size)
|
||||
// ALPHA_8 = 1 byte/px instead of 2 (RGB_565) or 4 (ARGB_8888).
|
||||
// The ImageView gets tinted white via the matrix — for a pure
|
||||
// black-and-white QR that's all we need and it halves heap usage
|
||||
// compared to the previous RGB_565 path.
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
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()
|
||||
}
|
||||
}
|
||||
bitmap.setPixels(pixels, 0, size, 0, 0, size, size)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal failure UI shown when Python.start() (Chaquopy) blew up.
|
||||
* Stack trace is hidden behind a "Show details" toggle so we don't
|
||||
* print user-path data on shared TV screens by default.
|
||||
*/
|
||||
private fun showFatalErrorScreen(error: Throwable) {
|
||||
Log.e(TAG, "Fatal init error — showing error screen", error)
|
||||
val stackText = Log.getStackTraceString(error)
|
||||
val container = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(48, 48, 48, 48)
|
||||
}
|
||||
val title = TextView(this).apply {
|
||||
text = getString(R.string.fatal_title)
|
||||
textSize = 22f
|
||||
}
|
||||
val description = TextView(this).apply {
|
||||
text = getString(R.string.fatal_body_prefix)
|
||||
textSize = 14f
|
||||
setPadding(0, 24, 0, 12)
|
||||
}
|
||||
val body = TextView(this).apply {
|
||||
text = stackText
|
||||
textSize = 12f
|
||||
setTextIsSelectable(true)
|
||||
visibility = View.GONE
|
||||
}
|
||||
val scroll = ScrollView(this).apply {
|
||||
addView(body)
|
||||
visibility = View.GONE
|
||||
}
|
||||
val toggleBtn = Button(this).apply {
|
||||
text = getString(R.string.fatal_show_details)
|
||||
setOnClickListener {
|
||||
val showing = scroll.visibility == View.VISIBLE
|
||||
scroll.visibility = if (showing) View.GONE else View.VISIBLE
|
||||
body.visibility = scroll.visibility
|
||||
text = getString(
|
||||
if (showing) R.string.fatal_show_details else R.string.fatal_hide_details,
|
||||
)
|
||||
}
|
||||
}
|
||||
val copyBtn = Button(this).apply {
|
||||
text = getString(R.string.fatal_copy_log)
|
||||
setOnClickListener {
|
||||
val cm = getSystemService(CLIPBOARD_SERVICE)
|
||||
as android.content.ClipboardManager
|
||||
cm.setPrimaryClip(
|
||||
android.content.ClipData.newPlainText("LedGrab error", stackText)
|
||||
)
|
||||
}
|
||||
}
|
||||
container.addView(title)
|
||||
container.addView(description)
|
||||
container.addView(toggleBtn)
|
||||
container.addView(copyBtn)
|
||||
container.addView(scroll)
|
||||
setContentView(container)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user to exempt LedGrab from battery optimization.
|
||||
* Strictly a phone-side concern (Doze/App Standby kill the FG
|
||||
* service after hours of sleep); essentially a no-op on TV boxes.
|
||||
* Only asked when autostart is turned on, which is itself only
|
||||
* available on rooted devices.
|
||||
*
|
||||
* 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request RECORD_AUDIO (API 29+) so the capture service can capture
|
||||
* system playback audio for audio-reactive lighting. Fire-and-forget,
|
||||
* like [ensureNotificationPermission]: capture still works without it
|
||||
* (just no audio), so we don't block on the result. If first granted
|
||||
* here, audio becomes available on the next Start.
|
||||
*/
|
||||
private fun ensureAudioPermission() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return
|
||||
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.RECORD_AUDIO),
|
||||
REQUEST_RECORD_AUDIO,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request CAMERA so the capture service can open the device camera for
|
||||
* on-device webcam capture. Fire-and-forget, like [ensureAudioPermission]:
|
||||
* capture still works without it (just no camera engine), so we don't block
|
||||
* on the result. Gated on actual camera hardware via FEATURE_CAMERA_ANY so
|
||||
* camera-less TV boxes (the common case) never see the prompt. The camera
|
||||
* is opened on demand only while a camera source is active — granting this
|
||||
* does not keep the camera on. If first granted here, the camera engine
|
||||
* becomes available on the next Start.
|
||||
*/
|
||||
private fun ensureCameraPermission() {
|
||||
if (!packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) return
|
||||
if (checkSelfPermission(Manifest.permission.CAMERA)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.CAMERA),
|
||||
REQUEST_CAMERA,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request BLUETOOTH_SCAN + BLUETOOTH_CONNECT (API 31+) so the embedded
|
||||
* server can discover and drive BLE LED controllers (SP110E / Triones /
|
||||
* Zengge). On API < 31 these are install-time legacy permissions
|
||||
* (BLUETOOTH / BLUETOOTH_ADMIN / ACCESS_FINE_LOCATION, maxSdk=30) and
|
||||
* need no runtime grant — so this is a no-op there. Fire-and-forget,
|
||||
* like [ensureAudioPermission]: screen capture works without BLE, and
|
||||
* BleBridge degrades gracefully (empty scan / failed connect) when the
|
||||
* grant is denied, so we don't block on the result. If first granted
|
||||
* here, BLE devices become reachable on the next scan/connect.
|
||||
*/
|
||||
private fun ensureBluetoothPermissions() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||
val needed = listOf(
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
).filter {
|
||||
checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (needed.isEmpty()) return
|
||||
@Suppress("DEPRECATION")
|
||||
requestPermissions(needed.toTypedArray(), REQUEST_BLUETOOTH)
|
||||
}
|
||||
|
||||
/** Whether the user has granted notification-listener access to this app. */
|
||||
private fun isNotificationAccessGranted(): Boolean =
|
||||
NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)
|
||||
|
||||
/** Open the system Notification-access screen (manual affordance / re-grant). */
|
||||
private fun openNotificationListenerSettings() {
|
||||
runCatching {
|
||||
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
|
||||
}.onFailure { Log.w(TAG, "Notification-access settings unavailable: ${it.message}") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Usage Access (PACKAGE_USAGE_STATS) is granted — needed by the
|
||||
* foreground-app automation rule. Delegates to the bridge's AppOps check.
|
||||
*/
|
||||
private fun isUsageAccessGranted(): Boolean = ForegroundAppBridge.hasUsageAccess()
|
||||
|
||||
/**
|
||||
* Open the system Usage-Access screen so the user can grant LedGrab access
|
||||
* for the foreground-app automation rule. Falls back to the generic Settings
|
||||
* screen on TV-box OEM builds that strip the dedicated intent.
|
||||
*/
|
||||
private fun openUsageAccessSettings() {
|
||||
runCatching {
|
||||
startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS))
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Usage-access settings unavailable: ${it.message}")
|
||||
runCatching { startActivity(Intent(Settings.ACTION_SETTINGS)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt-once-then-remember: the first time capture starts without
|
||||
* notification-listener access, open the settings screen so the user can
|
||||
* grant it — then never nag again (the manual "Grant notification access"
|
||||
* button stays available). Fire-and-forget like [ensureNotificationPermission].
|
||||
*/
|
||||
private fun ensureNotificationListenerAccess() {
|
||||
if (isNotificationAccessGranted()) return
|
||||
val prefs = getSharedPreferences(NOTIF_PREFS, MODE_PRIVATE)
|
||||
if (prefs.getBoolean(KEY_NOTIF_ACCESS_PROMPTED, false)) return
|
||||
prefs.edit().putBoolean(KEY_NOTIF_ACCESS_PROMPTED, true).apply()
|
||||
openNotificationListenerSettings()
|
||||
}
|
||||
|
||||
/**
|
||||
* Show each "Grant <permission> access" button only while that access is
|
||||
* missing, then re-wire the D-pad focus chain. Called on create and on resume
|
||||
* (access can change in Settings while we're backgrounded). The usage-access
|
||||
* button is a passive affordance (no auto-prompt at capture start) — the
|
||||
* primary guidance is the web-UI banner when an Android app rule needs it.
|
||||
*/
|
||||
private fun updateStoppedPermissionButtons() {
|
||||
if (!::grantNotificationButton.isInitialized) return
|
||||
grantNotificationButton.visibility =
|
||||
if (isNotificationAccessGranted()) View.GONE else View.VISIBLE
|
||||
grantUsageAccessButton.visibility =
|
||||
if (isUsageAccessGranted()) View.GONE else View.VISIBLE
|
||||
wireStoppedFocusChain()
|
||||
}
|
||||
|
||||
/**
|
||||
* Link the visible stopped-panel controls into a single up/down D-pad chain.
|
||||
* The optional controls (the grant-access buttons and the root-only autostart
|
||||
* checkbox) may be GONE, so the chain is computed from whatever is visible —
|
||||
* a static nextFocus pointing at a GONE view would strand the focus on a TV
|
||||
* remote.
|
||||
*/
|
||||
private fun wireStoppedFocusChain() {
|
||||
val chain = listOfNotNull(
|
||||
toggleButton,
|
||||
grantNotificationButton.takeIf { it.visibility == View.VISIBLE },
|
||||
grantUsageAccessButton.takeIf { it.visibility == View.VISIBLE },
|
||||
autostartCheck.takeIf { it.visibility == View.VISIBLE },
|
||||
)
|
||||
chain.forEachIndexed { i, view ->
|
||||
view.nextFocusUpId = (chain.getOrNull(i - 1) ?: view).id
|
||||
view.nextFocusDownId = (chain.getOrNull(i + 1) ?: view).id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkProperties
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import java.net.Inet4Address
|
||||
|
||||
/**
|
||||
* Network utilities for discovering the device's local IP address.
|
||||
*/
|
||||
object NetworkUtils {
|
||||
|
||||
/**
|
||||
* Return the device's local IPv4 address, preferring (in order):
|
||||
* - Ethernet (wired TV-box link)
|
||||
* - Wi-Fi
|
||||
* - any other transport
|
||||
* - whatever the active network reports
|
||||
*
|
||||
* Returns ``null`` only when no IPv4 link addresses exist at all.
|
||||
*
|
||||
* Why not just ``activeNetwork``: on TV boxes with both Ethernet
|
||||
* AND Wi-Fi connected, Android's active-network heuristic can
|
||||
* pick Wi-Fi while the user's phone is on the Ethernet subnet —
|
||||
* leading to a URL/QR that the phone can't reach.
|
||||
*/
|
||||
fun getLocalIpAddress(context: Context): String? {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
// TODO(AP-mode): On TV boxes acting as a Wi-Fi tether/hotspot,
|
||||
// TRANSPORT_WIFI here will resolve to the AP-side interface
|
||||
// (typically 192.168.43.x) which clients on the user's actual
|
||||
// home LAN can't reach. Detecting AP mode requires the @SystemApi
|
||||
// WifiManager.getWifiApState reflection trick — defer until a
|
||||
// user reports needing it.
|
||||
val networks = cm.allNetworks
|
||||
if (networks.isEmpty()) return ipv4Of(cm, cm.activeNetwork ?: return null)
|
||||
|
||||
val ranked = networks
|
||||
.mapNotNull { n ->
|
||||
val caps = cm.getNetworkCapabilities(n) ?: return@mapNotNull null
|
||||
val rank = when {
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 0
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 1
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> 3
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 4
|
||||
else -> 2
|
||||
}
|
||||
Triple(rank, n, caps)
|
||||
}
|
||||
.sortedBy { it.first }
|
||||
|
||||
for ((_, network, _) in ranked) {
|
||||
val ip = ipv4Of(cm, network)
|
||||
if (ip != null) return ip
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun ipv4Of(cm: ConnectivityManager, network: Network): String? {
|
||||
val props: LinkProperties = cm.getLinkProperties(network) ?: return null
|
||||
return props.linkAddresses
|
||||
.asSequence()
|
||||
.map { it.address }
|
||||
.filterIsInstance<Inet4Address>()
|
||||
.firstOrNull { !it.isLoopbackAddress && !it.isLinkLocalAddress }
|
||||
?.hostAddress
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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` (reused across
|
||||
* frames — see ScreenCapture / RootScreenrecord for buffer pools).
|
||||
*/
|
||||
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
|
||||
@Volatile private var androidAudioEngine: 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}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the Android playback-capture audio engine with the format
|
||||
* actually negotiated by [AudioCapture]'s `AudioRecord`. Must be called
|
||||
* before [pushAudio]. Caches the module handle for the per-block fast
|
||||
* path (same pattern as [configureCapture]).
|
||||
*/
|
||||
fun configureAudio(sampleRate: Int, channels: Int, chunkFrames: Int) {
|
||||
val py = Python.getInstance()
|
||||
val engine = py.getModule("ledgrab.core.audio.android_audio_engine")
|
||||
engine.callAttr("configure", sampleRate, channels, chunkFrames)
|
||||
androidAudioEngine = engine
|
||||
Log.i(TAG, "Android audio engine configured: sr=$sampleRate ch=$channels chunk=$chunkFrames")
|
||||
}
|
||||
|
||||
/**
|
||||
* Push one interleaved little-endian float32 PCM block to the Python
|
||||
* audio engine. Called from [AudioCapture]'s capture thread. The byte
|
||||
* array crosses the JNI boundary; Python copies it on receipt, so the
|
||||
* caller may reuse the same buffer for the next block.
|
||||
*/
|
||||
fun pushAudio(pcmFloat32: ByteArray) {
|
||||
if (!running) return
|
||||
val engine = androidAudioEngine ?: return
|
||||
try {
|
||||
engine.callAttr("push_samples", pcmFloat32)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to push audio: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate the Python audio engine. Called from [AudioCapture.stop].
|
||||
*/
|
||||
fun shutdownAudio() {
|
||||
val engine = androidAudioEngine ?: return
|
||||
try {
|
||||
engine.callAttr("shutdown")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to shut down audio engine: ${e.message}")
|
||||
}
|
||||
androidAudioEngine = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the LedGrab FastAPI server on a background thread.
|
||||
*
|
||||
* Passes [apiKey] through so the Python server's auth gate accepts
|
||||
* Bearer-authenticated LAN requests; null disables auth (loopback
|
||||
* only — see [ApiKeyManager]).
|
||||
*
|
||||
* This blocks until [stopServer] is called, so it runs in its own
|
||||
* thread.
|
||||
*/
|
||||
fun startServer(port: Int = 8080, apiKey: String? = null) {
|
||||
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")
|
||||
if (apiKey != null) {
|
||||
entry.callAttr("start_server", dataDir, port, apiKey)
|
||||
} else {
|
||||
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) and pass reusable buffers (see ScreenCapture's
|
||||
* buffer pool).
|
||||
*/
|
||||
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,219 @@
|
||||
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"
|
||||
|
||||
// Slice length for the cancellation-aware su probe wait loop. Short
|
||||
// enough that coroutine cancellation is honoured promptly, long enough
|
||||
// to avoid busy-spinning while Magisk's grant dialog is up.
|
||||
private const val POLL_SLICE_MS = 100L
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var process: Process? = null
|
||||
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 proc = ProcessBuilder("su", "-c", "id")
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
process = proc
|
||||
val outputBuilder = StringBuilder()
|
||||
val drain = Thread({
|
||||
try {
|
||||
BufferedReader(InputStreamReader(proc.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() }
|
||||
|
||||
// Cancellation-aware wait: callers run this on a coroutine
|
||||
// (MainActivity wraps it in runInterruptible), so a config change
|
||||
// mid-probe cancels the coroutine and interrupts this thread.
|
||||
// Poll waitFor() in short slices and honour interruption so we
|
||||
// don't leak the `su` child + its drain thread for up to 10s.
|
||||
// The catch(InterruptedException) below destroys the process; we
|
||||
// re-arm the interrupt and rethrow so coroutine cancellation
|
||||
// propagates cleanly.
|
||||
val deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds)
|
||||
var finished = false
|
||||
while (System.nanoTime() < deadlineNanos) {
|
||||
if (proc.waitFor(POLL_SLICE_MS, TimeUnit.MILLISECONDS)) {
|
||||
finished = true
|
||||
break
|
||||
}
|
||||
// Throws InterruptedException if the thread was interrupted
|
||||
// by coroutine cancellation — handled below to tear down.
|
||||
if (Thread.interrupted()) throw InterruptedException("su probe cancelled")
|
||||
}
|
||||
if (!finished) {
|
||||
proc.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 (proc.exitValue() != 0) {
|
||||
Log.w(TAG, "su -c id exited with ${proc.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: InterruptedException) {
|
||||
// Coroutine cancelled mid-probe (e.g. config change). Kill the
|
||||
// su child so it doesn't outlive the cancelled work, re-arm the
|
||||
// interrupt flag, and rethrow so the coroutine cancels cleanly.
|
||||
// Do NOT cache a result — the probe never completed.
|
||||
runCatching { process?.destroyForcibly() }
|
||||
Thread.currentThread().interrupt()
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "su invocation failed: ${e.message}")
|
||||
runCatching { process?.destroyForcibly() }
|
||||
false
|
||||
}
|
||||
|
||||
cachedGranted = granted
|
||||
return granted
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command as root.
|
||||
*
|
||||
* The [argv] array is passed to `su -c` as **a single string** built by
|
||||
* shell-quoting each element. This prevents the shell-injection class
|
||||
* of bug where a caller passes user-influenced data containing
|
||||
* spaces, semicolons, or backticks: each element is treated as a
|
||||
* single shell token regardless of contents.
|
||||
*
|
||||
* 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
|
||||
@JvmOverloads
|
||||
fun runAsRoot(argv: Array<String>, timeoutSeconds: Long = 5): Boolean {
|
||||
require(argv.isNotEmpty()) { "runAsRoot called with empty argv" }
|
||||
val quoted = argv.joinToString(" ") { shellQuote(it) }
|
||||
return execSu(quoted, timeoutSeconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience for fully-trusted constant commands (e.g.
|
||||
* ``runAsRoot("pkill -TERM screenrecord")``). DO NOT pass anything
|
||||
* derived from user input through this overload — use [runAsRoot]
|
||||
* with an argv array instead so each token is quoted individually.
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun runAsRoot(command: String, timeoutSeconds: Long = 5): Boolean {
|
||||
return execSu(command, timeoutSeconds)
|
||||
}
|
||||
|
||||
private fun execSu(shellLine: String, timeoutSeconds: Long): Boolean {
|
||||
return try {
|
||||
val process = ProcessBuilder("su", "-c", shellLine)
|
||||
.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('$shellLine') failed: ${e.message}")
|
||||
cachedGranted = null
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POSIX-shell-style single-quote escape. Wraps in single quotes and
|
||||
* escapes embedded single quotes as ``'\''`` so shell metacharacters
|
||||
* inside [s] are inert.
|
||||
*/
|
||||
private fun shellQuote(s: String): String {
|
||||
if (s.isEmpty()) return "''"
|
||||
// Optimisation: if the string contains only safe characters,
|
||||
// skip the quoting overhead. The set is intentionally narrow —
|
||||
// notably `=` is excluded because an unquoted "FOO=bar" at the
|
||||
// start of a command would be parsed as a shell variable
|
||||
// assignment, not a literal arg. Quoting it forces literal use.
|
||||
if (s.all { it.isLetterOrDigit() || it in "_-./" }) return s
|
||||
val sb = StringBuilder(s.length + 2)
|
||||
sb.append('\'')
|
||||
for (ch in s) {
|
||||
if (ch == '\'') sb.append("'\\''") else sb.append(ch)
|
||||
}
|
||||
sb.append('\'')
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** Forget the cached grant result — useful if Magisk permission was revoked. */
|
||||
@JvmStatic
|
||||
fun invalidateCache() {
|
||||
cachedGranted = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
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
|
||||
// How long to back off when MediaCodec has no input buffer free.
|
||||
// 50 ms keeps the input pump from busy-spinning if the decoder
|
||||
// is stalled (codec init, severe stall, etc.).
|
||||
private const val NO_BUFFER_BACKOFF_MS = 5L
|
||||
}
|
||||
|
||||
// Instance is single-use: stop() permanently disposes it. Callers
|
||||
// wanting to restart the pipeline must construct a new instance —
|
||||
// see CaptureService.restartRootPipeline().
|
||||
@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)
|
||||
// disposed gates duplicate-stop calls only — not start() after
|
||||
// stop() (which is unsupported, see note above). Set at the START
|
||||
// of cleanup so a second concurrent stop() (rare under @Synchronized
|
||||
// but possible if a future caller drops it) doesn't re-run runCatching
|
||||
// blocks against already-released resources.
|
||||
@Volatile private var disposed = false
|
||||
// Guards process respawn vs. concurrent disposal. The input pump
|
||||
// can spawn a fresh `su -c screenrecord` after EOF; without this
|
||||
// lock, stop() could destroy the OLD process between spawn and
|
||||
// assignment, leaving the new one orphaned (GPU encoder leak).
|
||||
private val processLock = Any()
|
||||
|
||||
// Reusable RGBA buffer for ImageReader callbacks (single-threaded
|
||||
// reader callback). See ScreenCapture for the rationale: avoids
|
||||
// ~15 MB/s of per-frame garbage at 30 fps × 480×270×4 B.
|
||||
private val frameBuffer: ByteArray = ByteArray(width * height * 4)
|
||||
|
||||
/** 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 {
|
||||
val reader = buildImageReader().also { imageReader = it }
|
||||
val codec = buildDecoder(reader).also { decoder = it }
|
||||
val proc = spawnScreenrecord() ?: run {
|
||||
stop()
|
||||
return false
|
||||
}
|
||||
process = proc
|
||||
startInputPump(proc.inputStream, codec)
|
||||
startOutputDrain(codec)
|
||||
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. Single-use: do not call start() again. */
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
if (disposed) return
|
||||
disposed = 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(arrayOf("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
|
||||
|
||||
// Use the same lock as the respawn path so we don't destroy a
|
||||
// not-yet-published process or leak one that was spawned after
|
||||
// we already destroyed the old reference.
|
||||
synchronized(processLock) {
|
||||
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, 3)
|
||||
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 rowBytes = width * pixelStride
|
||||
val expected = rowBytes * height
|
||||
if (rowStride == rowBytes && buffer.remaining() >= expected) {
|
||||
buffer.get(frameBuffer, 0, expected)
|
||||
} else {
|
||||
for (row in 0 until height) {
|
||||
buffer.position(row * rowStride)
|
||||
buffer.get(frameBuffer, row * rowBytes, rowBytes)
|
||||
}
|
||||
}
|
||||
// CONTRACT: frameBuffer is REUSED across frames (single-threaded
|
||||
// reader callback — no copy here). Safety depends on the Python
|
||||
// receiver copying the bytes before this callback returns and
|
||||
// overwrites the buffer for the next frame. It does:
|
||||
// PythonBridge.pushRootFrame → root_screenrecord_engine.push_frame
|
||||
// (server/src/ledgrab/core/capture_engines/root_screenrecord_engine.py)
|
||||
// does `rgba[:, :, :3].copy()`, so the queued frame owns its
|
||||
// pixels independently of this buffer. Do NOT remove that copy.
|
||||
bridge.pushRootFrame(frameBuffer, 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? {
|
||||
// argv form — passes safely through Root.runAsRoot's shell-quote
|
||||
// logic so future changes to flag values can't introduce injection.
|
||||
val args = arrayOf(
|
||||
"screenrecord",
|
||||
"--output-format=h264",
|
||||
"--size=${width}x$height",
|
||||
"--bit-rate=$bitRate",
|
||||
// Time limit 0 isn't supported; the largest accepted is 180s.
|
||||
// We restart the process ourselves if it exits early.
|
||||
"--time-limit=180",
|
||||
"-",
|
||||
)
|
||||
// Inline ProcessBuilder so we have direct access to the child's
|
||||
// stdout (Root.runAsRoot returns Boolean). We still pass args
|
||||
// unquoted because the entire array is a fixed program+flags
|
||||
// with no user-controlled content.
|
||||
return try {
|
||||
ProcessBuilder("su", "-c", args.joinToString(" "))
|
||||
.redirectErrorStream(false)
|
||||
.start()
|
||||
} 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")
|
||||
synchronized(processLock) {
|
||||
runCatching { process?.destroy() }
|
||||
process = null
|
||||
}
|
||||
val next = spawnScreenrecord()
|
||||
if (next == null) {
|
||||
// Avoid a tight loop if `su` is suddenly unhappy.
|
||||
try { Thread.sleep(500) } catch (_: InterruptedException) { break }
|
||||
continue@outer
|
||||
}
|
||||
// Publish the new process under the lock so a
|
||||
// concurrent stop() either (a) sees no process,
|
||||
// tears down later, and lets us assign it for
|
||||
// the destroy on the NEXT stop call — or (b) sees
|
||||
// !running and we destroy the new process ourselves.
|
||||
val accepted = synchronized(processLock) {
|
||||
if (!running) {
|
||||
false
|
||||
} else {
|
||||
process = next
|
||||
true
|
||||
}
|
||||
}
|
||||
if (!accepted) {
|
||||
// running flipped false between EOF and now —
|
||||
// someone called stop(). Drop the new process
|
||||
// on the floor; the codec and output thread
|
||||
// are stop()'s responsibility (it's the only
|
||||
// writer to `running`, so we don't need to
|
||||
// tear them down here).
|
||||
runCatching { next.destroy() }
|
||||
break@outer
|
||||
}
|
||||
stream = next.inputStream
|
||||
continue@outer
|
||||
}
|
||||
var offset = 0
|
||||
while (offset < n && running) {
|
||||
val index = codec.dequeueInputBuffer(50_000)
|
||||
if (index < 0) {
|
||||
// Codec is starved — back off briefly instead
|
||||
// of spinning. Without this, a stalled codec
|
||||
// burns 100% of one core hammering dequeue.
|
||||
try {
|
||||
Thread.sleep(NO_BUFFER_BACKOFF_MS)
|
||||
} catch (_: InterruptedException) {
|
||||
break
|
||||
}
|
||||
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,211 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
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.os.SystemClock
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Captures the Android screen via MediaProjection and feeds frames
|
||||
* to [PythonBridge].
|
||||
*
|
||||
* Frames are downscaled to roughly [targetWidth] x [targetHeight] before
|
||||
* crossing the JNI boundary to minimize overhead. The actual capture
|
||||
* dimensions preserve the source screen's aspect ratio (snapped to even
|
||||
* pixels for codec friendliness) so non-16:9 displays don't get
|
||||
* squashed.
|
||||
*/
|
||||
class ScreenCapture(
|
||||
private val projection: MediaProjection,
|
||||
private val metrics: DisplayMetrics,
|
||||
private val bridge: PythonBridge,
|
||||
targetWidth: Int = 480,
|
||||
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"
|
||||
}
|
||||
|
||||
// Snap to the source aspect ratio so we don't squash 21:9 / portrait
|
||||
// / rotated screens. Width is the budget; height follows.
|
||||
private val captureWidth: Int
|
||||
private val captureHeight: Int
|
||||
|
||||
init {
|
||||
val srcW = metrics.widthPixels.coerceAtLeast(1).toFloat()
|
||||
val srcH = metrics.heightPixels.coerceAtLeast(1).toFloat()
|
||||
val budget = targetWidth.coerceAtLeast(16)
|
||||
val aspect = srcW / srcH
|
||||
val w = budget
|
||||
val h = (w / aspect).toInt().coerceAtLeast(16)
|
||||
// Bias toward even dimensions — some encoders/ImageReaders are
|
||||
// unhappy with odd sizes when row strides come into play.
|
||||
captureWidth = (w and 1.inv()).coerceAtLeast(16)
|
||||
captureHeight = (h and 1.inv()).coerceAtLeast(16)
|
||||
if (captureWidth != targetWidth || captureHeight != targetHeight) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Capture size adjusted for ${srcW.toInt()}x${srcH.toInt()} " +
|
||||
"(${"%.2f".format(aspect)}:1) → ${captureWidth}x$captureHeight",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Reusable RGBA frame buffer — sized once for the capture dimensions.
|
||||
// The capture handler is single-threaded so no synchronisation is
|
||||
// required around this buffer (each callback runs to completion
|
||||
// before the next is dispatched). Eliminates ~15 MB/s of per-frame
|
||||
// garbage at 30 fps × 480×270×4 B that previously caused GC pauses
|
||||
// on low-end TV boxes.
|
||||
private val frameBuffer: ByteArray = ByteArray(captureWidth * captureHeight * 4)
|
||||
|
||||
// Monotonic frame pacing. `nextFrameNanos` is the target render
|
||||
// time of the next frame; carrying it forward as an accumulator
|
||||
// avoids the integer-division drift the wall-clock version had
|
||||
// (e.g. 30 fps → 33 ms produced ~30.3 fps).
|
||||
private val frameIntervalNanos = (1_000_000_000L / targetFps.coerceAtLeast(1))
|
||||
private var nextFrameNanos = 0L
|
||||
|
||||
/**
|
||||
* Start capturing the screen.
|
||||
*/
|
||||
fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
nextFrameNanos = SystemClock.elapsedRealtimeNanos()
|
||||
|
||||
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)")
|
||||
// We're on captureHandler's thread here — calling stop()
|
||||
// directly would self-join captureThread (handler.join()
|
||||
// from inside the handler thread hangs until the join
|
||||
// timeout, then closes resources while we're STILL
|
||||
// inside this callback). Just flip `running` to halt
|
||||
// frame processing and hand off to the service; its
|
||||
// onDestroy will call stop() from the main thread,
|
||||
// which is safe to join captureThread from.
|
||||
running = false
|
||||
onProjectionStopped()
|
||||
}
|
||||
}, captureHandler)
|
||||
|
||||
imageReader = ImageReader.newInstance(
|
||||
captureWidth,
|
||||
captureHeight,
|
||||
PixelFormat.RGBA_8888,
|
||||
3, // maxImages — small ring buffer; 3 is more forgiving than 2 under jitter
|
||||
)
|
||||
|
||||
imageReader?.setOnImageAvailableListener({ reader ->
|
||||
if (!running) return@setOnImageAvailableListener
|
||||
|
||||
val now = SystemClock.elapsedRealtimeNanos()
|
||||
if (now < nextFrameNanos) {
|
||||
// Too early — drop this image to stay on cadence.
|
||||
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
|
||||
val rowBytes = captureWidth * pixelStride
|
||||
val expected = rowBytes * captureHeight
|
||||
|
||||
// Fill the reusable buffer. Two paths:
|
||||
// - rowStride == rowBytes: bulk get into the buffer
|
||||
// - rowStride > rowBytes: row-by-row copy stripping padding
|
||||
if (rowStride == rowBytes && buffer.remaining() >= expected) {
|
||||
buffer.get(frameBuffer, 0, expected)
|
||||
} else {
|
||||
for (row in 0 until captureHeight) {
|
||||
buffer.position(row * rowStride)
|
||||
buffer.get(frameBuffer, row * rowBytes, rowBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// CONTRACT: frameBuffer is REUSED across frames (single-threaded
|
||||
// capture handler — no copy here). Safety depends on the Python
|
||||
// receiver copying the bytes before this callback returns and
|
||||
// overwrites the buffer for the next frame. It does:
|
||||
// PythonBridge.pushFrame → mediaprojection_engine.push_frame
|
||||
// (server/src/ledgrab/core/capture_engines/mediaprojection_engine.py)
|
||||
// does `rgba[:, :, :3].copy()`, so the queued frame owns its
|
||||
// pixels independently of this buffer. Do NOT remove that copy.
|
||||
bridge.pushFrame(frameBuffer, captureWidth, captureHeight)
|
||||
|
||||
// Advance the pacing accumulator. If we fell badly behind
|
||||
// (long GC, JNI stall), snap forward to "now" instead of
|
||||
// accumulating a burst of catch-up frames.
|
||||
nextFrameNanos += frameIntervalNanos
|
||||
if (now - nextFrameNanos > frameIntervalNanos * 4) {
|
||||
nextFrameNanos = now + frameIntervalNanos
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Frame processing error: ${e.message}")
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
}, captureHandler)
|
||||
|
||||
virtualDisplay = projection.createVirtualDisplay(
|
||||
VIRTUAL_DISPLAY_NAME,
|
||||
captureWidth,
|
||||
captureHeight,
|
||||
metrics.densityDpi,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
imageReader?.surface,
|
||||
null,
|
||||
captureHandler,
|
||||
)
|
||||
|
||||
Log.i(TAG, "Screen capture started (${captureWidth}x${captureHeight} @ ${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,307 @@
|
||||
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 androidx.core.content.ContextCompat
|
||||
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 ourPackage = app.packageName
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
// Defence-in-depth: the receiver is registered as
|
||||
// RECEIVER_NOT_EXPORTED, but on pre-API-33 platforms
|
||||
// older Android versions historically defaulted to
|
||||
// exported. Also enforce the package check here so an
|
||||
// explicit-intent attack from another app on the device
|
||||
// is rejected even if the OS treats us as exported.
|
||||
if (intent.`package` != null && intent.`package` != ourPackage) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Ignoring USB permission broadcast from " +
|
||||
"package='${intent.`package`}' (not us)",
|
||||
)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
// ContextCompat handles the RECEIVER_NOT_EXPORTED flag correctly
|
||||
// across all supported API levels (it's a no-op on platforms
|
||||
// where the flag doesn't exist, and explicit on API ≥33 where
|
||||
// Android enforces it).
|
||||
ContextCompat.registerReceiver(
|
||||
app,
|
||||
receiver,
|
||||
filter,
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
}
|
||||
|
||||
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,58 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Android TV launcher banner: 320x180 landscape.
|
||||
Shown on the leanback home row. The previous build reused the square
|
||||
launcher icon, which letterboxed badly. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="320dp"
|
||||
android:height="180dp"
|
||||
android:viewportWidth="320"
|
||||
android:viewportHeight="180">
|
||||
<!-- Background -->
|
||||
<path
|
||||
android:fillColor="#0d1117"
|
||||
android:pathData="M0,0 L320,0 L320,180 L0,180 Z" />
|
||||
<!-- Subtle teal glow top-left -->
|
||||
<path
|
||||
android:fillColor="#1A64ffda"
|
||||
android:pathData="M0,0 L160,0 L160,90 L0,90 Z" />
|
||||
<!-- Subtle purple glow bottom-right -->
|
||||
<path
|
||||
android:fillColor="#15bb86fc"
|
||||
android:pathData="M160,90 L320,90 L320,180 L160,180 Z" />
|
||||
<!-- TV body, centered -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M88,56 L196,56 Q204,56 204,64 L204,116 Q204,124 196,124 L88,124 Q80,124 80,116 L80,64 Q80,56 88,56 Z" />
|
||||
<!-- TV screen -->
|
||||
<path
|
||||
android:fillColor="#161b22"
|
||||
android:pathData="M92,60 L192,60 Q196,60 196,64 L196,116 Q196,120 192,120 L92,120 Q88,120 88,116 L88,64 Q88,60 92,60 Z" />
|
||||
<!-- LED glow strips -->
|
||||
<path
|
||||
android:fillColor="#64ffda"
|
||||
android:fillAlpha="0.8"
|
||||
android:pathData="M94,50 L190,50 L190,54 L94,54 Z" />
|
||||
<path
|
||||
android:fillColor="#bb86fc"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M72,62 L76,62 L76,118 L72,118 Z" />
|
||||
<path
|
||||
android:fillColor="#ff6b6b"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M208,62 L212,62 L212,118 L208,118 Z" />
|
||||
<path
|
||||
android:fillColor="#ffd93d"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M94,126 L190,126 L190,130 L94,130 Z" />
|
||||
<!-- Wordmark "LedGrab" — drawn as paths so we don't depend on the
|
||||
system font cache being warm at TV launch. -->
|
||||
<!-- L -->
|
||||
<path android:fillColor="#64ffda"
|
||||
android:pathData="M222,72 L228,72 L228,100 L240,100 L240,106 L222,106 Z" />
|
||||
<!-- e -->
|
||||
<path android:fillColor="#e6edf3"
|
||||
android:pathData="M244,82 L260,82 Q264,82 264,86 L264,94 L250,94 L250,100 L262,100 L262,106 L246,106 Q244,106 244,104 Z M250,86 L250,90 L258,90 L258,86 Z" />
|
||||
<!-- d -->
|
||||
<path android:fillColor="#e6edf3"
|
||||
android:pathData="M266,72 L272,72 L272,82 L284,82 Q286,82 286,84 L286,106 L268,106 Q266,106 266,104 Z M272,88 L272,100 L280,100 L280,88 Z" />
|
||||
</vector>
|
||||
@@ -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,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Static fallback for the status dot. The animated version
|
||||
(animated_status_dot.xml) is used at runtime; this is what
|
||||
XML rendering tools show in the editor. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/green_status" />
|
||||
<size android:width="18dp" android:height="18dp" />
|
||||
</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,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Monochrome status-bar icon. Android requires white-on-transparent for
|
||||
notification icons since API 21 - reusing the colored launcher would
|
||||
render as a gray blob. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFFFF">
|
||||
<!-- TV body -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M5,7 L19,7 Q20,7 20,8 L20,16 Q20,17 19,17 L5,17 Q4,17 4,16 L4,8 Q4,7 5,7 Z M5.5,8.5 L5.5,15.5 L18.5,15.5 L18.5,8.5 Z" />
|
||||
<!-- TV stand -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M10,17 L10,18.5 L14,18.5 L14,17 Z M9,19 L15,19 L15,20 L9,20 Z" />
|
||||
<!-- LED glow strips around the TV (bright dots) -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M6,5.5 L18,5.5 L18,6.5 L6,6.5 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Splash screen icon (API 31+ uses a 1:1 vector inside a 240dp circle).
|
||||
The SplashScreen API masks this with a circle automatically. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="240dp"
|
||||
android:height="240dp"
|
||||
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 strips, brighter on splash for impact -->
|
||||
<path
|
||||
android:fillColor="#64ffda"
|
||||
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
|
||||
<path
|
||||
android:fillColor="#bb86fc"
|
||||
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
|
||||
<path
|
||||
android:fillColor="#ff6b6b"
|
||||
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
|
||||
<path
|
||||
android:fillColor="#ffd93d"
|
||||
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,133 @@
|
||||
<?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" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tagline_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="24dp" />
|
||||
|
||||
<!-- Transient status (root probing / permission denial). Always
|
||||
present so the layout doesn't reflow when text appears. -->
|
||||
<TextView
|
||||
android:id="@+id/status_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="32dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="20sp"
|
||||
android:layout_marginBottom="32dp"
|
||||
tools:text="Checking root access…" />
|
||||
|
||||
<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"
|
||||
android:nextFocusDown="@+id/autostart_check" />
|
||||
|
||||
<!-- Shown only while notification-listener access is missing. The D-pad
|
||||
focus chain is wired at runtime (wireStoppedFocusChain) because this
|
||||
button and the autostart checkbox are both conditionally visible. -->
|
||||
<Button
|
||||
android:id="@+id/grant_notification_button"
|
||||
style="@style/Widget.LedGrab.Button.Secondary"
|
||||
android:layout_width="320dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="@string/btn_grant_notification_access"
|
||||
android:textSize="18sp"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- Shown only while Usage Access is missing (needed by the foreground-app
|
||||
automation rule). Like the grant-notification button, its D-pad focus
|
||||
chain is wired at runtime (wireStoppedFocusChain). -->
|
||||
<Button
|
||||
android:id="@+id/grant_usage_access_button"
|
||||
style="@style/Widget.LedGrab.Button.Secondary"
|
||||
android:layout_width="320dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="@string/btn_grant_usage_access"
|
||||
android:textSize="18sp"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true"
|
||||
android:visibility="gone" />
|
||||
|
||||
<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"
|
||||
android:nextFocusUp="@id/toggle_button" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Version at bottom (always visible — looks polished on TV idle). -->
|
||||
<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 — deferred-inflate via ViewStub so first paint is
|
||||
cheaper and the inflater doesn't measure two competing layouts. -->
|
||||
<ViewStub
|
||||
android:id="@+id/running_panel_stub"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:inflatedId="@+id/running_panel"
|
||||
android:layout="@layout/panel_running"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,124 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- RUNNING STATE -->
|
||||
<LinearLayout
|
||||
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:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="120dp"
|
||||
android:paddingEnd="120dp"
|
||||
android:paddingTop="80dp"
|
||||
android:paddingBottom="80dp">
|
||||
|
||||
<!-- 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:id="@+id/status_dot"
|
||||
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"
|
||||
android:nextFocusUp="@id/stop_button_running"
|
||||
android:nextFocusDown="@id/stop_button_running" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Right: QR code + fallback hint -->
|
||||
<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" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="280dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/scan_fallback_hint"
|
||||
android:textColor="@color/text_hint"
|
||||
android:textSize="14sp"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="6dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -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,31 @@
|
||||
<?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_starting">Запуск…</string>
|
||||
<string name="btn_stop">Стоп</string>
|
||||
<string name="status_running">Работает</string>
|
||||
<string name="status_checking_root">Проверка root-доступа…</string>
|
||||
<string name="status_permission_denied">Доступ запрещён — для захвата экрана требуется разрешение</string>
|
||||
<string name="status_no_network">Нет сети — подключите Wi-Fi или Ethernet</string>
|
||||
<string name="label_web_ui">Адрес веб-интерфейса</string>
|
||||
<string name="scan_to_configure">Сканируйте для настройки</string>
|
||||
<string name="scan_fallback_hint">или откройте этот адрес с любого устройства в сети</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>
|
||||
<string name="fatal_title">Не удалось запустить LedGrab</string>
|
||||
<string name="fatal_body_prefix">Ошибка инициализации Python:</string>
|
||||
<string name="fatal_copy_log">Скопировать журнал</string>
|
||||
<string name="fatal_show_details">Показать подробности</string>
|
||||
<string name="fatal_hide_details">Скрыть подробности</string>
|
||||
<string name="notification_channel_name">Захват LedGrab</string>
|
||||
<string name="notification_channel_description">Отображается, пока LedGrab захватывает экран.</string>
|
||||
<string name="notification_title">LedGrab работает</string>
|
||||
<string name="notification_text">Веб-интерфейс: %1$s</string>
|
||||
<string name="notification_listener_label">Захват уведомлений LedGrab</string>
|
||||
<string name="btn_grant_notification_access">Разрешить доступ к уведомлениям</string>
|
||||
<string name="btn_grant_usage_access">Разрешить доступ к статистике использования</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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_starting">正在启动…</string>
|
||||
<string name="btn_stop">停止</string>
|
||||
<string name="status_running">运行中</string>
|
||||
<string name="status_checking_root">正在检查 root 权限…</string>
|
||||
<string name="status_permission_denied">权限被拒绝 — 屏幕捕获需要授权</string>
|
||||
<string name="status_no_network">无网络 — 请连接 Wi-Fi 或以太网</string>
|
||||
<string name="label_web_ui">Web界面地址</string>
|
||||
<string name="scan_to_configure">扫码配置</string>
|
||||
<string name="scan_fallback_hint">或在同一网络的任何设备上访问上方网址</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>
|
||||
<string name="fatal_title">LedGrab 启动失败</string>
|
||||
<string name="fatal_body_prefix">Python 运行时初始化失败:</string>
|
||||
<string name="fatal_copy_log">复制日志</string>
|
||||
<string name="fatal_show_details">显示详情</string>
|
||||
<string name="fatal_hide_details">隐藏详情</string>
|
||||
<string name="notification_channel_name">LedGrab 屏幕捕获</string>
|
||||
<string name="notification_channel_description">LedGrab 捕获屏幕时显示。</string>
|
||||
<string name="notification_title">LedGrab 运行中</string>
|
||||
<string name="notification_text">Web界面:%1$s</string>
|
||||
<string name="notification_listener_label">LedGrab 通知捕获</string>
|
||||
<string name="btn_grant_notification_access">授予通知访问权限</string>
|
||||
<string name="btn_grant_usage_access">授予使用情况访问权限</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,31 @@
|
||||
<?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_starting">Starting…</string>
|
||||
<string name="btn_stop">Stop</string>
|
||||
<string name="status_running">Running</string>
|
||||
<string name="status_checking_root">Checking root access…</string>
|
||||
<string name="status_permission_denied">Permission denied — screen capture requires authorization</string>
|
||||
<string name="status_no_network">No network — connect Wi-Fi or Ethernet</string>
|
||||
<string name="label_web_ui">Web UI address</string>
|
||||
<string name="scan_to_configure">Scan to configure</string>
|
||||
<string name="scan_fallback_hint">or visit the URL above on any device on this network</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>
|
||||
<string name="fatal_title">LedGrab failed to start</string>
|
||||
<string name="fatal_body_prefix">Python runtime initialization failed:</string>
|
||||
<string name="fatal_copy_log">Copy log</string>
|
||||
<string name="fatal_show_details">Show details</string>
|
||||
<string name="fatal_hide_details">Hide details</string>
|
||||
<string name="notification_channel_name">LedGrab capture</string>
|
||||
<string name="notification_channel_description">Shows while LedGrab is capturing the screen.</string>
|
||||
<string name="notification_title">LedGrab Running</string>
|
||||
<string name="notification_text">Web UI: %1$s</string>
|
||||
<string name="notification_listener_label">LedGrab notification capture</string>
|
||||
<string name="btn_grant_notification_access">Grant notification access</string>
|
||||
<string name="btn_grant_usage_access">Grant usage access</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,40 @@
|
||||
<?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>
|
||||
|
||||
<!-- Splash screen theme. Compatible across API levels via the
|
||||
androidx.core:core-splashscreen library. On API 31+ the system
|
||||
splash uses the foreground icon; on older versions the launch
|
||||
theme just paints the navy background, which is harmless. -->
|
||||
<style name="Theme.LedGrab.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/bg_navy</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.LedGrab</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,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
LedGrab is a LAN-only app:
|
||||
- Inbound: web UI / API on the device (HTTP, port 8080)
|
||||
- Outbound: WLED HTTP/UDP, Home Assistant, MQTT brokers, mDNS
|
||||
|
||||
All of these are plaintext on the local network. Android's network
|
||||
security config doesn't support CIDR allowlists, so we cannot
|
||||
restrict cleartext to RFC1918 ranges declaratively — we have to
|
||||
permit cleartext base-wide.
|
||||
|
||||
Defence-in-depth that ACTUALLY mitigates this:
|
||||
1. Inbound: the FastAPI server in this app rejects non-loopback
|
||||
requests when no API key is configured (see ledgrab.api.auth).
|
||||
The Android launcher auto-generates an API key on first run
|
||||
(see ApiKeyManager.kt) and injects it via the
|
||||
LEDGRAB_AUTH__API_KEYS env var before uvicorn starts. The
|
||||
user's phone receives the key by scanning the QR, which
|
||||
embeds the key as a URL fragment (never logged server-side).
|
||||
2. Outbound: targets are validated by net_classify in the Python
|
||||
layer (LAN-only HTTP, SSRF-safe).
|
||||
|
||||
DO NOT remove the cleartext permission without first migrating
|
||||
every LAN peer to HTTPS — most WLED firmware, mDNS, and the LAN
|
||||
HTTP server itself rely on this flag.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Cross-compile pydantic-core for Android across all supported ABIs:
|
||||
# arm64-v8a (primary — modern TV hardware)
|
||||
# x86_64 (modern emulators)
|
||||
# x86 (legacy emulators)
|
||||
# armeabi-v7a (32-bit ARMv7 — older cheap TV boxes like X96 mini, MeCool)
|
||||
#
|
||||
# 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/armv7a-linux-android(eabi)
|
||||
# - 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 4 ABIs
|
||||
# ./build-pydantic-core.sh arm64 # build a single ABI
|
||||
# ./build-pydantic-core.sh arm64 x86_64 # build a subset
|
||||
# ./build-pydantic-core.sh armv7 # 32-bit ARM only
|
||||
#
|
||||
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"
|
||||
"armv7 armv7-linux-androideabi armv7a-linux-androideabi${API_LEVEL} cross-sysconfig-armv7"
|
||||
)
|
||||
|
||||
declare -A ABI_TAG_MAP=(
|
||||
[arm64]="arm64_v8a"
|
||||
[x86_64]="x86_64"
|
||||
[x86]="x86"
|
||||
[armv7]="armeabi_v7a"
|
||||
)
|
||||
|
||||
# ── Select which ABIs to build ──────────────────────────────────────
|
||||
SELECTED=("$@")
|
||||
if [ ${#SELECTED[@]} -eq 0 ]; then
|
||||
SELECTED=(arm64 x86_64 x86 armv7)
|
||||
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.
@@ -69,6 +69,16 @@ copy_app_files() {
|
||||
# Clean up source maps and __pycache__
|
||||
find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true
|
||||
find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# Patch the fallback version in the bundled __init__.py. Bundled installs
|
||||
# strip ledgrab-*.dist-info from site-packages, so importlib.metadata
|
||||
# falls back to this literal at runtime — and a stale literal is what
|
||||
# silently shipped v0.4.2 reporting "0.3.0" in the WebUI.
|
||||
local bundled_init="$APP_DIR/src/ledgrab/__init__.py"
|
||||
if [ -f "$bundled_init" ] && [ -n "${VERSION_CLEAN:-}" ]; then
|
||||
sed -i "s/_FALLBACK_VERSION = \"[^\"]*\"/_FALLBACK_VERSION = \"${VERSION_CLEAN}\"/" "$bundled_init"
|
||||
echo " Patched _FALLBACK_VERSION -> ${VERSION_CLEAN}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Site-packages cleanup ────────────────────────────────────
|
||||
@@ -143,8 +153,8 @@ cleanup_site_packages() {
|
||||
find "$sp_dir" -name "*.$ext_suffix" -exec strip --strip-debug {} \; 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── Remove wled_controller if pip-installed ───────────────
|
||||
rm -rf "$sp_dir"/wled_controller* "$sp_dir"/wled*.dist-info 2>/dev/null || true
|
||||
# ── 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)
|
||||
@@ -191,7 +201,7 @@ compile_and_strip_sources() {
|
||||
|
||||
# ── Import smoke test ────────────────────────────────────────
|
||||
#
|
||||
# Verifies that every top-level dependency that wled_controller actually
|
||||
# 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).
|
||||
@@ -200,7 +210,7 @@ compile_and_strip_sources() {
|
||||
# Args:
|
||||
# $1 — path to site-packages to test against
|
||||
# $2 — python executable
|
||||
# $3 — (optional) extra PYTHONPATH entry (e.g. app/src for wled_controller)
|
||||
# $3 — (optional) extra PYTHONPATH entry (e.g. app/src for ledgrab)
|
||||
|
||||
smoke_test_imports() {
|
||||
local sp_dir="$1"
|
||||
@@ -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)
|
||||
@@ -286,9 +298,10 @@ compile_and_strip_sources "$SITE_PACKAGES" "python"
|
||||
echo " Verifying required submodules exist after cleanup..."
|
||||
for required in \
|
||||
"numpy/linalg" "numpy/lib" "numpy/matrixlib" "numpy/ma" \
|
||||
"zeroconf/_services"; do
|
||||
"zeroconf/_services" \
|
||||
"cryptography" "cffi" "just_playback"; do
|
||||
if [ ! -d "$SITE_PACKAGES/$required" ] && [ ! -f "$SITE_PACKAGES/$required.py" ] && [ ! -f "$SITE_PACKAGES/$required.pyc" ]; then
|
||||
echo " ERROR: $required missing from site-packages — cleanup_site_packages removed something required. Aborting."
|
||||
echo " ERROR: $required missing from site-packages — either cleanup_site_packages removed something required, or DEPS is out of sync with pyproject.toml. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
@@ -321,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
|
||||
@@ -336,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"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -83,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"
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Generate LedGrab app icon assets.
|
||||
|
||||
Concept: "Spectrum Aperture" — a rounded-square frame (the screen/display)
|
||||
traced by a continuous RGB color-wheel stroke (the bias-light LED strip),
|
||||
on a near-black canvas with a soft chromatic bloom behind it.
|
||||
|
||||
Outputs:
|
||||
server/src/ledgrab/static/icons/icon-512.png (standard, opaque vignette bg)
|
||||
server/src/ledgrab/static/icons/icon-192.png (downscale of 512)
|
||||
server/src/ledgrab/static/icons/icon-512-maskable.png (safe-area padded, opaque)
|
||||
server/src/ledgrab/static/icons/icon-tray.png (256, transparent bg, frame + glow)
|
||||
server/src/ledgrab/static/icons/icon.ico (16/24/32/48/64/128/256)
|
||||
|
||||
Run from repo root:
|
||||
py -3.13 build/generate_icon.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import colorsys
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
|
||||
# ── Tunables ────────────────────────────────────────────────────────────
|
||||
SUPERSAMPLE = 4 # render at 4x and downsample for crispness
|
||||
BASE = 1024 # logical canvas size
|
||||
HQ = BASE * SUPERSAMPLE # render canvas
|
||||
|
||||
BG_TOP = (12, 14, 22) # near-black, faint cool tint
|
||||
BG_BOTTOM = (6, 7, 12) # darker at edges (vignette feel)
|
||||
|
||||
FRAME_INSET = 0.18 # margin from canvas edge to frame (fraction)
|
||||
FRAME_RADIUS = 0.22 # corner radius (fraction of frame side)
|
||||
FRAME_STROKE = 0.085 # stroke width (fraction of canvas)
|
||||
BLOOM_OPACITY = 0.62 # outer bloom strength (0–1)
|
||||
INNER_GLOW_OPACITY = 0.38 # inner chromatic reflection strength
|
||||
|
||||
# Hue rotation offset so red sits at the top
|
||||
HUE_OFFSET = -90.0 # degrees (negative = counter-clockwise shift)
|
||||
|
||||
|
||||
def lerp(a: float, b: float, t: float) -> float:
|
||||
return a + (b - a) * t
|
||||
|
||||
|
||||
def hue_to_rgb(hue_deg: float) -> tuple[int, int, int]:
|
||||
"""Bright, slightly desaturated spectral color (LED-like)."""
|
||||
h = (hue_deg % 360) / 360.0
|
||||
r, g, b = colorsys.hls_to_rgb(h, 0.58, 0.92)
|
||||
return int(r * 255), int(g * 255), int(b * 255)
|
||||
|
||||
|
||||
def vignette_background(size: int) -> Image.Image:
|
||||
"""Dark canvas with a soft radial vignette + faint scanline noise."""
|
||||
img = Image.new("RGB", (size, size), BG_TOP)
|
||||
px = img.load()
|
||||
cx, cy = size / 2, size / 2
|
||||
max_r = math.hypot(cx, cy)
|
||||
for y in range(size):
|
||||
for x in range(size):
|
||||
d = math.hypot(x - cx, y - cy) / max_r
|
||||
t = min(1.0, d**1.6)
|
||||
px[x, y] = (
|
||||
int(lerp(BG_TOP[0], BG_BOTTOM[0], t)),
|
||||
int(lerp(BG_TOP[1], BG_BOTTOM[1], t)),
|
||||
int(lerp(BG_TOP[2], BG_BOTTOM[2], t)),
|
||||
)
|
||||
return img
|
||||
|
||||
|
||||
def draw_chromatic_bloom(size: int) -> Image.Image:
|
||||
"""Soft, large chromatic glow behind the frame — the bias-light effect."""
|
||||
layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
|
||||
cx, cy = size / 2, size / 2
|
||||
radius = size * 0.36
|
||||
blob_r = int(size * 0.30)
|
||||
n_blobs = 24
|
||||
|
||||
for i in range(n_blobs):
|
||||
a = i / n_blobs * 360.0
|
||||
bx = cx + math.cos(math.radians(a - 90)) * radius
|
||||
by = cy + math.sin(math.radians(a - 90)) * radius
|
||||
r, g, b = hue_to_rgb(a + HUE_OFFSET)
|
||||
alpha = int(255 * BLOOM_OPACITY * 0.55)
|
||||
draw.ellipse(
|
||||
(bx - blob_r, by - blob_r, bx + blob_r, by + blob_r),
|
||||
fill=(r, g, b, alpha),
|
||||
)
|
||||
|
||||
# Heavy blur → continuous, dreamy halo
|
||||
layer = layer.filter(ImageFilter.GaussianBlur(radius=size * 0.10))
|
||||
return layer
|
||||
|
||||
|
||||
def rounded_rect_mask(size: int, inset: int, radius: int, stroke: int) -> Image.Image:
|
||||
"""L-mode mask of a rounded-rect ring (the frame stroke region)."""
|
||||
mask = Image.new("L", (size, size), 0)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
box_outer = (inset, inset, size - inset, size - inset)
|
||||
box_inner = (
|
||||
inset + stroke,
|
||||
inset + stroke,
|
||||
size - inset - stroke,
|
||||
size - inset - stroke,
|
||||
)
|
||||
r_outer = radius
|
||||
r_inner = max(0, radius - stroke)
|
||||
draw.rounded_rectangle(box_outer, radius=r_outer, fill=255)
|
||||
draw.rounded_rectangle(box_inner, radius=r_inner, fill=0)
|
||||
return mask
|
||||
|
||||
|
||||
def draw_spectrum_frame(size: int) -> Image.Image:
|
||||
"""Draw the rounded-square frame stroke filled with a hue-rotation gradient.
|
||||
|
||||
Strategy: paint a full-canvas angular hue gradient (centered), then
|
||||
clip it with the rounded-ring mask. This guarantees a continuous,
|
||||
seam-free color flow around the entire frame.
|
||||
"""
|
||||
cx, cy = size / 2, size / 2
|
||||
|
||||
gradient = Image.new("RGB", (size, size), (0, 0, 0))
|
||||
gpx = gradient.load()
|
||||
for y in range(size):
|
||||
dy = y - cy
|
||||
for x in range(size):
|
||||
dx = x - cx
|
||||
ang = math.degrees(math.atan2(dy, dx)) + 90.0 # 0° = top
|
||||
r, g, b = hue_to_rgb(ang + HUE_OFFSET)
|
||||
gpx[x, y] = (r, g, b)
|
||||
|
||||
inset = int(size * FRAME_INSET)
|
||||
frame_side = size - 2 * inset
|
||||
stroke = int(size * FRAME_STROKE)
|
||||
radius = int(frame_side * FRAME_RADIUS)
|
||||
|
||||
mask = rounded_rect_mask(size, inset, radius, stroke)
|
||||
|
||||
out = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
out.paste(gradient, (0, 0), mask)
|
||||
return out
|
||||
|
||||
|
||||
def draw_inner_screen(size: int) -> Image.Image:
|
||||
"""Subtle dark rounded square inside the frame, with faint chromatic
|
||||
inner reflection along the edges — like a screen catching ambient light."""
|
||||
inset = int(size * FRAME_INSET)
|
||||
stroke = int(size * FRAME_STROKE)
|
||||
frame_side = size - 2 * inset
|
||||
radius = int(frame_side * FRAME_RADIUS)
|
||||
|
||||
pad = int(stroke * 0.35)
|
||||
box = (
|
||||
inset + stroke + pad,
|
||||
inset + stroke + pad,
|
||||
size - inset - stroke - pad,
|
||||
size - inset - stroke - pad,
|
||||
)
|
||||
r_inner = max(0, radius - stroke - pad)
|
||||
|
||||
layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
# Dark fill, very slight cool tint
|
||||
draw.rounded_rectangle(box, radius=r_inner, fill=(10, 12, 18, 255))
|
||||
|
||||
# Inner chromatic glow: same spectrum, very soft, clipped to the screen
|
||||
bloom = draw_chromatic_bloom(size)
|
||||
screen_mask = Image.new("L", (size, size), 0)
|
||||
ImageDraw.Draw(screen_mask).rounded_rectangle(box, radius=r_inner, fill=255)
|
||||
|
||||
bloom_alpha = bloom.split()[-1].point(lambda v: int(v * INNER_GLOW_OPACITY))
|
||||
bloom.putalpha(bloom_alpha)
|
||||
|
||||
masked_bloom = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
masked_bloom.paste(bloom, (0, 0), screen_mask)
|
||||
layer.alpha_composite(masked_bloom)
|
||||
|
||||
# Faint highlight glint top-left
|
||||
glint = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
gdraw = ImageDraw.Draw(glint)
|
||||
glint_box = (
|
||||
box[0] + int(frame_side * 0.04),
|
||||
box[1] + int(frame_side * 0.04),
|
||||
box[0] + int(frame_side * 0.42),
|
||||
box[1] + int(frame_side * 0.18),
|
||||
)
|
||||
gdraw.rounded_rectangle(glint_box, radius=int(frame_side * 0.05), fill=(255, 255, 255, 22))
|
||||
glint = glint.filter(ImageFilter.GaussianBlur(radius=size * 0.012))
|
||||
masked_glint = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
masked_glint.paste(glint, (0, 0), screen_mask)
|
||||
layer.alpha_composite(masked_glint)
|
||||
|
||||
return layer
|
||||
|
||||
|
||||
def add_outer_frame_glow(frame_rgba: Image.Image) -> Image.Image:
|
||||
"""Take the spectrum frame and produce a blurred, brightened copy for glow."""
|
||||
glow = frame_rgba.copy()
|
||||
# Slightly inflate brightness for glow
|
||||
r, g, b, a = glow.split()
|
||||
glow = Image.merge("RGBA", (r, g, b, a.point(lambda v: min(255, int(v * 0.85)))))
|
||||
glow = glow.filter(ImageFilter.GaussianBlur(radius=glow.width * 0.025))
|
||||
return glow
|
||||
|
||||
|
||||
def render_tray(size: int) -> Image.Image:
|
||||
"""Render a tray-optimised icon: transparent background, bolder frame,
|
||||
tight outer glow. Designed to read clearly at 16–32 px on top of any
|
||||
taskbar color."""
|
||||
hq = size * SUPERSAMPLE
|
||||
|
||||
# Pull the frame inward a touch and beef up the stroke so it reads at 16 px.
|
||||
global FRAME_INSET, FRAME_STROKE
|
||||
saved_inset, saved_stroke = FRAME_INSET, FRAME_STROKE
|
||||
FRAME_INSET = 0.13
|
||||
FRAME_STROKE = 0.115
|
||||
try:
|
||||
frame = draw_spectrum_frame(hq)
|
||||
finally:
|
||||
FRAME_INSET, FRAME_STROKE = saved_inset, saved_stroke
|
||||
|
||||
# Tight, bright glow that doesn't bleed past the tray cell.
|
||||
glow = frame.copy()
|
||||
r, g, b, a = glow.split()
|
||||
glow = Image.merge("RGBA", (r, g, b, a.point(lambda v: min(255, int(v * 0.95)))))
|
||||
glow = glow.filter(ImageFilter.GaussianBlur(radius=hq * 0.012))
|
||||
|
||||
canvas = Image.new("RGBA", (hq, hq), (0, 0, 0, 0))
|
||||
canvas.alpha_composite(glow)
|
||||
canvas.alpha_composite(frame)
|
||||
|
||||
return canvas.resize((size, size), Image.LANCZOS)
|
||||
|
||||
|
||||
def render(size: int, *, maskable: bool = False) -> Image.Image:
|
||||
"""Render the full icon at the given size."""
|
||||
hq = size * SUPERSAMPLE
|
||||
|
||||
if maskable:
|
||||
# Maskable: pad inward so the entire icon survives a circular crop.
|
||||
# We render the standard composition at 80% of canvas size, centered.
|
||||
bg = Image.new("RGB", (hq, hq), BG_BOTTOM).convert("RGBA")
|
||||
bg.paste(vignette_background(hq), (0, 0))
|
||||
|
||||
inner = render(size, maskable=False).resize((int(hq * 0.78), int(hq * 0.78)), Image.LANCZOS)
|
||||
# Strip the bg from the inner render: composite the spectrum
|
||||
# parts on top of our maskable background.
|
||||
ox = (hq - inner.width) // 2
|
||||
oy = (hq - inner.height) // 2
|
||||
bg.alpha_composite(inner, (ox, oy))
|
||||
return bg.resize((size, size), Image.LANCZOS)
|
||||
|
||||
bg = vignette_background(hq).convert("RGBA")
|
||||
bloom = draw_chromatic_bloom(hq)
|
||||
frame = draw_spectrum_frame(hq)
|
||||
frame_glow = add_outer_frame_glow(frame)
|
||||
inner_screen = draw_inner_screen(hq)
|
||||
|
||||
# Composite order: bg → bloom → frame_glow → inner_screen → frame
|
||||
canvas = Image.new("RGBA", (hq, hq), (0, 0, 0, 0))
|
||||
canvas.alpha_composite(bg)
|
||||
canvas.alpha_composite(bloom)
|
||||
canvas.alpha_composite(frame_glow)
|
||||
canvas.alpha_composite(inner_screen)
|
||||
canvas.alpha_composite(frame)
|
||||
|
||||
return canvas.resize((size, size), Image.LANCZOS)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
targets = [
|
||||
repo_root / "server" / "src" / "ledgrab" / "static" / "icons",
|
||||
repo_root
|
||||
/ "android"
|
||||
/ "app"
|
||||
/ "build"
|
||||
/ "python"
|
||||
/ "sources"
|
||||
/ "debug"
|
||||
/ "ledgrab"
|
||||
/ "static"
|
||||
/ "icons",
|
||||
]
|
||||
|
||||
print("Rendering 1024 master...")
|
||||
master = render(1024, maskable=False)
|
||||
|
||||
print("Rendering maskable 1024 master...")
|
||||
maskable_master = render(1024, maskable=True)
|
||||
|
||||
print("Rendering tray 512 master (transparent bg)...")
|
||||
tray_master = render_tray(512)
|
||||
|
||||
for icons_dir in targets:
|
||||
if not icons_dir.exists():
|
||||
print(f" skip (missing): {icons_dir}")
|
||||
continue
|
||||
|
||||
out_512 = icons_dir / "icon-512.png"
|
||||
out_192 = icons_dir / "icon-192.png"
|
||||
out_mask = icons_dir / "icon-512-maskable.png"
|
||||
out_tray = icons_dir / "icon-tray.png"
|
||||
out_ico = icons_dir / "icon.ico"
|
||||
|
||||
master.resize((512, 512), Image.LANCZOS).save(out_512, "PNG", optimize=True)
|
||||
master.resize((192, 192), Image.LANCZOS).save(out_192, "PNG", optimize=True)
|
||||
maskable_master.resize((512, 512), Image.LANCZOS).save(out_mask, "PNG", optimize=True)
|
||||
tray_master.save(out_tray, "PNG", optimize=True)
|
||||
|
||||
# Pre-resize each frame from the 1024 master for maximum crispness.
|
||||
# Pass them via the `sizes` arg so Pillow embeds every variant.
|
||||
ico_sizes = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]
|
||||
# Use the tray (transparent-bg) variant for ICO frames so the file/
|
||||
# taskbar icon doesn't show a dark tile against light backgrounds.
|
||||
ico_source = tray_master.resize((256, 256), Image.LANCZOS)
|
||||
ico_source.save(out_ico, format="ICO", sizes=ico_sizes)
|
||||
|
||||
print(f" wrote: {icons_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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,8 +30,8 @@ SetCompressor /SOLID lzma
|
||||
|
||||
; ── Modern UI Configuration ─────────────────────────────────
|
||||
|
||||
!define MUI_ICON "server\src\wled_controller\static\icons\icon.ico"
|
||||
!define MUI_UNICON "server\src\wled_controller\static\icons\icon.ico"
|
||||
!define MUI_ICON "..\server\src\ledgrab\static\icons\icon.ico"
|
||||
!define MUI_UNICON "..\server\src\ledgrab\static\icons\icon.ico"
|
||||
!define MUI_ABORTWARNING
|
||||
|
||||
; ── Pages ───────────────────────────────────────────────────
|
||||
@@ -56,9 +56,10 @@ SetCompressor /SOLID lzma
|
||||
; ── Functions ─────────────────────────────────────────────
|
||||
|
||||
Function LaunchApp
|
||||
; Only launch the app — do NOT open the browser here. A manual launch (no
|
||||
; --autostart) makes the app open the WebUI itself once /health responds,
|
||||
; so opening the URL here too made the page appear twice.
|
||||
ExecShell "open" "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"'
|
||||
Sleep 2000
|
||||
ExecShell "open" "http://localhost:8080/"
|
||||
FunctionEnd
|
||||
|
||||
; Detect running instance before install (file lock check on python.exe)
|
||||
@@ -98,12 +99,18 @@ Section "!${APPNAME} (required)" SecCore
|
||||
RMDir /r "$INSTDIR\app"
|
||||
RMDir /r "$INSTDIR\scripts"
|
||||
Delete "$INSTDIR\LedGrab.bat"
|
||||
; Legacy leftovers from the wled_controller-era install. The current
|
||||
; build does not ship debug.bat, but upgrades from older versions left
|
||||
; one behind with a stale `-m wled_controller` command that gives a
|
||||
; misleading ModuleNotFoundError when run. Remove it on upgrade.
|
||||
Delete "$INSTDIR\debug.bat"
|
||||
Delete "$INSTDIR\debug.log"
|
||||
|
||||
; Copy the entire portable build
|
||||
File /r "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"
|
||||
@@ -116,7 +123,7 @@ Section "!${APPNAME} (required)" SecCore
|
||||
CreateDirectory "$SMPROGRAMS\${APPNAME}"
|
||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"$INSTDIR\app\src\wled_controller\static\icons\icon.ico" 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
|
||||
@@ -132,11 +139,11 @@ Section "!${APPNAME} (required)" SecCore
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
|
||||
"InstallLocation" "$INSTDIR"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
|
||||
"DisplayIcon" "$INSTDIR\app\src\wled_controller\static\icons\icon.ico"
|
||||
"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}" \
|
||||
@@ -152,13 +159,16 @@ SectionEnd
|
||||
Section "Desktop shortcut" SecDesktop
|
||||
CreateShortcut "$DESKTOP\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"$INSTDIR\app\src\wled_controller\static\icons\icon.ico" 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\app\src\wled_controller\static\icons\icon.ico" 0
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}" --autostart' \
|
||||
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
|
||||
SectionEnd
|
||||
|
||||
; ── Section Descriptions ────────────────────────────────────
|
||||
@@ -187,6 +197,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/)
|
||||
+17
-10
@@ -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,21 +26,27 @@ 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`**
|
||||
|
||||
### 4. `build-docker`
|
||||
- Plain `docker build` + `docker push` (no Buildx — TrueNAS runners lack nested networking)
|
||||
- Plain `docker build` + `docker push` for **amd64** (no Buildx — TrueNAS runners lack nested networking)
|
||||
- Registry: `{gitea_host}/{repo}:{tag}`
|
||||
- Tags: `v0.x.x`, `0.x.x`, and `latest` (stable only, not alpha/beta/rc)
|
||||
- **arm64 is best-effort** (Raspberry Pi / arm64 HAOS): a `continue-on-error` step
|
||||
cross-builds arm64 via **QEMU binfmt** (`tonistiigi/binfmt`) + `docker manifest`
|
||||
(NOT buildx — sidesteps the docker-container-driver networking limit) and folds
|
||||
amd64 + arm64 into multi-arch manifest lists under the same tags, plus
|
||||
`:{tag}-amd64` / `:{tag}-arm64` arch-suffixed tags. If the runner can't run
|
||||
privileged binfmt the step is skipped and the amd64 tags above remain valid.
|
||||
|
||||
## Build Scripts
|
||||
|
||||
| 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 +78,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 +142,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
|
||||
|
||||
|
||||
+15
-1
@@ -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.
|
||||
|
||||
@@ -96,6 +96,20 @@ Both widgets hide the native `<select>` but keep it in the DOM with its value in
|
||||
|
||||
**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`:
|
||||
|
||||
@@ -8,36 +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/` |
|
||||
|
||||
Demo mode can also be triggered via the `WLED_DEMO` environment variable (`true`, `1`, or `yes`). This works with any launch method — Python, Docker (`-e WLED_DEMO=true`), or the installed app (`set WLED_DEMO=true` before `LedGrab.bat`).
|
||||
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.
|
||||
@@ -45,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`)
|
||||
@@ -52,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`)
|
||||
@@ -68,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,182 +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_HA_LIGHT,
|
||||
DATA_COORDINATOR,
|
||||
DATA_EVENT_LISTENER,
|
||||
)
|
||||
from .coordinator import WLEDScreenControllerCoordinator
|
||||
from .event_listener import EventStreamListener
|
||||
|
||||
_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()
|
||||
|
||||
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")
|
||||
if target_type == TARGET_TYPE_HA_LIGHT:
|
||||
model = "HA Light Target"
|
||||
else:
|
||||
model = "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_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:
|
||||
"""Detect target/scene list changes and trigger reload."""
|
||||
nonlocal known_target_ids, known_scene_ids
|
||||
|
||||
if not coordinator.data:
|
||||
return
|
||||
|
||||
targets = coordinator.data.get("targets", {})
|
||||
|
||||
# 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_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,22 +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_HA_LIGHT = "ha_light"
|
||||
|
||||
# Data keys stored in hass.data[DOMAIN][entry_id]
|
||||
DATA_COORDINATOR = "coordinator"
|
||||
DATA_EVENT_LISTENER = "event_listener"
|
||||
@@ -1,426 +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,
|
||||
)
|
||||
|
||||
_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
|
||||
|
||||
return target_id, {
|
||||
"info": target,
|
||||
"state": state,
|
||||
"metrics": metrics,
|
||||
}
|
||||
|
||||
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_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 _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,233 +0,0 @@
|
||||
"""Number platform for LED Screen Controller (device brightness & HA light settings)."""
|
||||
|
||||
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_HA_LIGHT
|
||||
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 number entities."""
|
||||
data = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
|
||||
|
||||
entities: list[NumberEntity] = []
|
||||
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"]
|
||||
target_type = info.get("target_type", "led")
|
||||
|
||||
if target_type == TARGET_TYPE_HA_LIGHT:
|
||||
# HA Light target — expose tunable settings
|
||||
entities.append(HALightUpdateRate(coordinator, target_id, entry.entry_id))
|
||||
entities.append(HALightTransition(coordinator, target_id, entry.entry_id))
|
||||
entities.append(HALightMinBrightness(coordinator, target_id, entry.entry_id))
|
||||
entities.append(HALightColorTolerance(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))
|
||||
|
||||
|
||||
# --- HA Light target number entities ---
|
||||
|
||||
|
||||
class _HALightNumberBase(CoordinatorEntity, NumberEntity):
|
||||
"""Base class for HA Light target number entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_mode = NumberMode.SLIDER
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
target_id: str,
|
||||
entry_id: str,
|
||||
*,
|
||||
field_name: str,
|
||||
) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._target_id = target_id
|
||||
self._entry_id = entry_id
|
||||
self._field_name = field_name
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
return {"identifiers": {(DOMAIN, self._target_id)}}
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
target_data = self._get_target_data()
|
||||
if not target_data:
|
||||
return None
|
||||
return target_data.get("info", {}).get(self._field_name)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._get_target_data() is not None
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
await self.coordinator.update_target(self._target_id, **{self._field_name: round(value, 2)})
|
||||
|
||||
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 HALightUpdateRate(_HALightNumberBase):
|
||||
"""Update rate (Hz) for an HA Light target."""
|
||||
|
||||
_attr_native_min_value = 0.5
|
||||
_attr_native_max_value = 5.0
|
||||
_attr_native_step = 0.5
|
||||
_attr_native_unit_of_measurement = "Hz"
|
||||
_attr_icon = "mdi:update"
|
||||
|
||||
def __init__(
|
||||
self, coordinator: WLEDScreenControllerCoordinator, target_id: str, entry_id: str
|
||||
) -> None:
|
||||
super().__init__(coordinator, target_id, entry_id, field_name="update_rate")
|
||||
self._attr_unique_id = f"{target_id}_update_rate"
|
||||
self._attr_translation_key = "ha_light_update_rate"
|
||||
|
||||
|
||||
class HALightTransition(_HALightNumberBase):
|
||||
"""Transition time (seconds) for an HA Light target."""
|
||||
|
||||
_attr_native_min_value = 0.0
|
||||
_attr_native_max_value = 10.0
|
||||
_attr_native_step = 0.1
|
||||
_attr_native_unit_of_measurement = "s"
|
||||
_attr_icon = "mdi:transition-masked"
|
||||
|
||||
def __init__(
|
||||
self, coordinator: WLEDScreenControllerCoordinator, target_id: str, entry_id: str
|
||||
) -> None:
|
||||
super().__init__(coordinator, target_id, entry_id, field_name="transition")
|
||||
self._attr_unique_id = f"{target_id}_transition"
|
||||
self._attr_translation_key = "ha_light_transition"
|
||||
|
||||
|
||||
class HALightMinBrightness(_HALightNumberBase):
|
||||
"""Minimum brightness threshold for an HA Light target."""
|
||||
|
||||
_attr_native_min_value = 0
|
||||
_attr_native_max_value = 255
|
||||
_attr_native_step = 1
|
||||
_attr_icon = "mdi:brightness-4"
|
||||
|
||||
def __init__(
|
||||
self, coordinator: WLEDScreenControllerCoordinator, target_id: str, entry_id: str
|
||||
) -> None:
|
||||
super().__init__(coordinator, target_id, entry_id, field_name="min_brightness_threshold")
|
||||
self._attr_unique_id = f"{target_id}_min_brightness"
|
||||
self._attr_translation_key = "ha_light_min_brightness"
|
||||
|
||||
|
||||
class HALightColorTolerance(_HALightNumberBase):
|
||||
"""Color tolerance (RGB delta skip threshold) for an HA Light target."""
|
||||
|
||||
_attr_native_min_value = 0
|
||||
_attr_native_max_value = 50
|
||||
_attr_native_step = 1
|
||||
_attr_icon = "mdi:palette-outline"
|
||||
|
||||
def __init__(
|
||||
self, coordinator: WLEDScreenControllerCoordinator, target_id: str, entry_id: str
|
||||
) -> None:
|
||||
super().__init__(coordinator, target_id, entry_id, field_name="color_tolerance")
|
||||
self._attr_unique_id = f"{target_id}_color_tolerance"
|
||||
self._attr_translation_key = "ha_light_color_tolerance"
|
||||
@@ -1,178 +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_HA_LIGHT
|
||||
from .coordinator import WLEDScreenControllerCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
NONE_OPTION = "\u2014 None \u2014"
|
||||
|
||||
|
||||
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"]
|
||||
target_type = info.get("target_type", "led")
|
||||
|
||||
# Both LED and HA Light targets have a CSS source
|
||||
entities.append(CSSSourceSelect(coordinator, target_id, entry.entry_id))
|
||||
|
||||
# Only LED targets have a brightness value source
|
||||
if target_type != TARGET_TYPE_HA_LIGHT:
|
||||
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 a 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
|
||||
# BindableFloat: brightness is either a plain float or {"value": float, "source_id": str}
|
||||
brightness = target_data["info"].get("brightness", "")
|
||||
if isinstance(brightness, dict):
|
||||
current_id = brightness.get("source_id", "")
|
||||
else:
|
||||
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": 1.0, "source_id": source_id} if source_id else 1.0,
|
||||
)
|
||||
@@ -1,225 +0,0 @@
|
||||
"""Sensor platform for LED Screen Controller."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
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_HA_LIGHT,
|
||||
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 sensors."""
|
||||
data = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
|
||||
|
||||
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 mapped lights sensor for HA Light targets
|
||||
info = target_data["info"]
|
||||
if info.get("target_type") == TARGET_TYPE_HA_LIGHT:
|
||||
entities.append(HALightMappedLightsSensor(coordinator, target_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 HALightMappedLightsSensor(CoordinatorEntity, SensorEntity):
|
||||
"""Sensor showing the number of mapped HA lights for an HA Light target."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_icon = "mdi:lightbulb-group"
|
||||
|
||||
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}_mapped_lights"
|
||||
self._attr_translation_key = "mapped_lights"
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return device information."""
|
||||
return {"identifiers": {(DOMAIN, self._target_id)}}
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
"""Return the number of mapped lights."""
|
||||
target_data = self._get_target_data()
|
||||
if not target_data:
|
||||
return None
|
||||
mappings = target_data.get("info", {}).get("light_mappings", [])
|
||||
return len(mappings)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return light mapping details as attributes."""
|
||||
target_data = self._get_target_data()
|
||||
if not target_data:
|
||||
return {}
|
||||
mappings = target_data.get("info", {}).get("light_mappings", [])
|
||||
entity_ids = [m.get("entity_id", "") for m in mappings]
|
||||
return {
|
||||
"entity_ids": entity_ids,
|
||||
"mappings": [
|
||||
{
|
||||
"entity_id": m.get("entity_id", ""),
|
||||
"led_start": m.get("led_start", 0),
|
||||
"led_end": m.get("led_end", -1),
|
||||
"brightness_scale": m.get("brightness_scale", 1.0),
|
||||
}
|
||||
for m in mappings
|
||||
],
|
||||
}
|
||||
|
||||
@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)
|
||||
@@ -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,103 +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"
|
||||
}
|
||||
},
|
||||
"mapped_lights": {
|
||||
"name": "Mapped Lights"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"brightness": {
|
||||
"name": "Brightness"
|
||||
},
|
||||
"ha_light_update_rate": {
|
||||
"name": "Update Rate"
|
||||
},
|
||||
"ha_light_transition": {
|
||||
"name": "Transition"
|
||||
},
|
||||
"ha_light_min_brightness": {
|
||||
"name": "Min Brightness"
|
||||
},
|
||||
"ha_light_color_tolerance": {
|
||||
"name": "Color Tolerance"
|
||||
}
|
||||
},
|
||||
"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,87 +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"
|
||||
}
|
||||
},
|
||||
"mapped_lights": {
|
||||
"name": "Mapped Lights"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"brightness": {
|
||||
"name": "Brightness"
|
||||
},
|
||||
"ha_light_update_rate": {
|
||||
"name": "Update Rate"
|
||||
},
|
||||
"ha_light_transition": {
|
||||
"name": "Transition"
|
||||
},
|
||||
"ha_light_min_brightness": {
|
||||
"name": "Min Brightness"
|
||||
},
|
||||
"ha_light_color_tolerance": {
|
||||
"name": "Color Tolerance"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"color_strip_source": {
|
||||
"name": "Color Strip Source"
|
||||
},
|
||||
"brightness_source": {
|
||||
"name": "Brightness Source"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +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": "Недоступен"
|
||||
}
|
||||
},
|
||||
"mapped_lights": {
|
||||
"name": "Привязанные светильники"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"brightness": {
|
||||
"name": "Яркость"
|
||||
},
|
||||
"ha_light_update_rate": {
|
||||
"name": "Частота обновления"
|
||||
},
|
||||
"ha_light_transition": {
|
||||
"name": "Переход"
|
||||
},
|
||||
"ha_light_min_brightness": {
|
||||
"name": "Мин. яркость"
|
||||
},
|
||||
"ha_light_color_tolerance": {
|
||||
"name": "Допуск цвета"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"color_strip_source": {
|
||||
"name": "Источник цветовой полосы"
|
||||
},
|
||||
"brightness_source": {
|
||||
"name": "Источник яркости"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+721
-251
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user