Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,232 @@
|
||||
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'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve build label
|
||||
id: label
|
||||
run: |
|
||||
REF="${{ gitea.ref_name }}"
|
||||
if echo "$REF" | grep -qE '^v[0-9]'; then
|
||||
LABEL="${REF#v}"
|
||||
IS_RELEASE="true"
|
||||
elif [ -n "${{ inputs.version }}" ]; then
|
||||
LABEL="${{ inputs.version }}"
|
||||
IS_RELEASE="false"
|
||||
else
|
||||
LABEL="dev-${{ gitea.sha }}"
|
||||
IS_RELEASE="false"
|
||||
fi
|
||||
LABEL="${LABEL:0:40}"
|
||||
echo "label=$LABEL" >> "$GITHUB_OUTPUT"
|
||||
echo "is_release=$IS_RELEASE" >> "$GITHUB_OUTPUT"
|
||||
echo "Build label: $LABEL (release=$IS_RELEASE)"
|
||||
|
||||
- name: Setup JDK ${{ env.JAVA_VERSION }}
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Setup Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Setup Android SDK + NDK
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SDK_ROOT="$HOME/android-sdk"
|
||||
mkdir -p "$SDK_ROOT/cmdline-tools"
|
||||
cd "$SDK_ROOT/cmdline-tools"
|
||||
curl -sSL --retry 3 \
|
||||
-o cmdline-tools.zip \
|
||||
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip"
|
||||
unzip -q cmdline-tools.zip
|
||||
rm cmdline-tools.zip
|
||||
mv cmdline-tools latest
|
||||
|
||||
export ANDROID_SDK_ROOT="$SDK_ROOT"
|
||||
export ANDROID_HOME="$SDK_ROOT"
|
||||
SDKMANAGER="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager"
|
||||
|
||||
yes | "$SDKMANAGER" --licenses > /dev/null 2>&1 || true
|
||||
"$SDKMANAGER" --install \
|
||||
"platform-tools" \
|
||||
"platforms;${ANDROID_SDK_PLATFORM}" \
|
||||
"build-tools;${ANDROID_BUILD_TOOLS}" \
|
||||
"ndk;${ANDROID_NDK_VERSION}" > /dev/null
|
||||
|
||||
echo "ANDROID_SDK_ROOT=$SDK_ROOT" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_HOME=$SDK_ROOT" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_NDK_HOME=$SDK_ROOT/ndk/${ANDROID_NDK_VERSION}" >> "$GITHUB_ENV"
|
||||
echo "$SDK_ROOT/platform-tools" >> "$GITHUB_PATH"
|
||||
echo "$SDK_ROOT/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Create local.properties
|
||||
run: |
|
||||
echo "sdk.dir=$ANDROID_SDK_ROOT" > android/local.properties
|
||||
|
||||
- name: Link Python source (junction equivalent)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Chaquopy reads Python modules from android/app/src/main/python/
|
||||
# On Windows dev machines this is a directory junction; on Linux
|
||||
# CI use a symlink. The parent dir is .gitignore'd (the junction
|
||||
# target is the real content), so we have to create it first.
|
||||
mkdir -p android/app/src/main/python
|
||||
ln -sfn "$(pwd)/server/src/ledgrab" android/app/src/main/python/ledgrab
|
||||
ls -la android/app/src/main/python/
|
||||
# Sanity check — readlink resolves the link and the directory exists.
|
||||
test -d android/app/src/main/python/ledgrab
|
||||
|
||||
- name: Decode signing keystore
|
||||
id: keystore
|
||||
if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }}
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
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:
|
||||
|
||||
@@ -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.
|
||||
@@ -111,6 +123,9 @@ jobs:
|
||||
# ── Windows portable ZIP (cross-built from Linux) ─────────
|
||||
build-windows:
|
||||
needs: create-release
|
||||
# `!cancelled()` lets this job run even when create-release was skipped
|
||||
# (dispatch) or failed. The attach step itself is still push-gated.
|
||||
if: ${{ !cancelled() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -135,8 +150,8 @@ jobs:
|
||||
|
||||
- name: Cross-build Windows distribution
|
||||
run: |
|
||||
chmod +x build-dist-windows.sh
|
||||
./build-dist-windows.sh "${{ gitea.ref_name }}"
|
||||
chmod +x build/build-dist-windows.sh
|
||||
./build/build-dist-windows.sh "${{ gitea.ref_name }}"
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
@@ -148,6 +163,8 @@ jobs:
|
||||
retention-days: 90
|
||||
|
||||
- name: Attach assets to release
|
||||
# Push (tag) only — dispatch runs produce artifacts but no release.
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||
run: |
|
||||
@@ -183,6 +200,7 @@ jobs:
|
||||
# ── 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 +225,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,6 +236,7 @@ jobs:
|
||||
retention-days: 90
|
||||
|
||||
- name: Attach tarball to release
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||
run: |
|
||||
@@ -246,6 +265,7 @@ jobs:
|
||||
# ── 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 +289,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 +299,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 +321,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 }}"
|
||||
|
||||
+14
-1
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Claude Instructions for WLED Screen Controller
|
||||
# Claude Instructions for LedGrab
|
||||
|
||||
## Code Search
|
||||
|
||||
@@ -28,8 +28,21 @@ ast-index changed --base master # Show symbols changed in current bran
|
||||
## Project Structure
|
||||
|
||||
- `/server` — Python FastAPI backend (see [server/CLAUDE.md](server/CLAUDE.md))
|
||||
- `/android` — Android TV app (Kotlin shell + embedded Python via Chaquopy)
|
||||
- `/contexts` — Context files for Claude (frontend conventions, graph editor, Chrome tools, server ops, demo mode)
|
||||
|
||||
## Android Dependency Sync (CRITICAL)
|
||||
|
||||
The Android app (`android/app/build.gradle.kts`) installs the server package with `--no-deps` and lists Android-compatible dependencies **explicitly** in the Chaquopy `pip {}` block. This is because `server/pyproject.toml` includes desktop-only packages (mss, psutil, sounddevice, etc.) that have no Android wheels.
|
||||
|
||||
**When adding a new dependency to `server/pyproject.toml`:**
|
||||
|
||||
1. If the package is **pure Python or has Chaquopy wheels** (check [Chaquopy PyPI](https://chaquo.com/pypi-13.1/)), also add it to `android/app/build.gradle.kts` in the `pip { install(...) }` block
|
||||
2. If the package is **desktop-only** (native C/Rust extension without Android support), do NOT add it to `build.gradle.kts` — and guard its import with `try/except ImportError` in Python code
|
||||
3. If unsure, check Chaquopy's package index first
|
||||
|
||||
**Incident context:** Chaquopy's pip runs on the build machine (Windows), not on Android. Platform markers like `sys_platform != 'linux'` evaluate against the BUILD host, not the target device. `pip install --exclude` does not exist. The only reliable way to exclude packages is to not list them.
|
||||
|
||||
## Context Files
|
||||
|
||||
| File | When to read |
|
||||
|
||||
+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)
|
||||
|
||||
@@ -87,8 +87,8 @@ A Home Assistant integration exposes devices as entities for smart home automati
|
||||
### Docker (recommended)
|
||||
|
||||
```bash
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
|
||||
cd wled-screen-controller/server
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
|
||||
cd ledgrab/server
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
@@ -97,8 +97,8 @@ docker compose up -d
|
||||
Requires Python 3.11+ and Node.js 18+.
|
||||
|
||||
```bash
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
|
||||
cd wled-screen-controller/server
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
|
||||
cd ledgrab/server
|
||||
|
||||
# Build the frontend bundle
|
||||
npm ci && npm run build
|
||||
@@ -112,7 +112,7 @@ pip install .
|
||||
# Start the server
|
||||
export PYTHONPATH=$(pwd)/src # Linux/Mac
|
||||
# set PYTHONPATH=%CD%\src # Windows
|
||||
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||
uvicorn ledgrab.main:app --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
Open **http://localhost:8080** to access the dashboard.
|
||||
@@ -125,17 +125,17 @@ See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, includin
|
||||
|
||||
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
|
||||
LEDGRAB_DEMO=true uvicorn ledgrab.main:app --host 0.0.0.0 --port 8081
|
||||
|
||||
# Windows (installed app)
|
||||
set WLED_DEMO=true
|
||||
set LEDGRAB_DEMO=true
|
||||
LedGrab.bat
|
||||
```
|
||||
|
||||
@@ -144,9 +144,9 @@ Demo mode uses port **8081**, config file `config/demo_config.yaml`, and stores
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
wled-screen-controller/
|
||||
ledgrab/
|
||||
├── server/ # Python FastAPI backend
|
||||
│ ├── src/wled_controller/
|
||||
│ ├── src/ledgrab/
|
||||
│ │ ├── main.py # Application entry point
|
||||
│ │ ├── config.py # YAML + env var configuration
|
||||
│ │ ├── api/
|
||||
@@ -171,8 +171,6 @@ wled-screen-controller/
|
||||
│ ├── tests/ # pytest suite
|
||||
│ ├── Dockerfile
|
||||
│ └── docker-compose.yml
|
||||
├── custom_components/ # Home Assistant integration (HACS)
|
||||
│ └── wled_screen_controller/
|
||||
├── docs/
|
||||
│ ├── API.md # REST API reference
|
||||
│ └── CALIBRATION.md # LED calibration guide
|
||||
@@ -182,7 +180,7 @@ wled-screen-controller/
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `server/config/default_config.yaml` or use environment variables with the `WLED_` prefix:
|
||||
Edit `server/config/default_config.yaml` or use environment variables with the `LEDGRAB_` prefix:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
@@ -200,11 +198,11 @@ storage:
|
||||
|
||||
logging:
|
||||
format: "json"
|
||||
file: "logs/wled_controller.log"
|
||||
file: "logs/ledgrab.log"
|
||||
max_size_mb: 100
|
||||
```
|
||||
|
||||
Environment variable override example: `WLED_SERVER__PORT=9090`.
|
||||
Environment variable override example: `LEDGRAB_SERVER__PORT=9090`.
|
||||
|
||||
## API
|
||||
|
||||
@@ -234,9 +232,7 @@ See [docs/CALIBRATION.md](docs/CALIBRATION.md) for a step-by-step guide.
|
||||
|
||||
## Home Assistant
|
||||
|
||||
Install via HACS (add as a custom repository) or manually copy `custom_components/wled_screen_controller/` into your HA config directory. The integration creates light, switch, sensor, and number entities for each configured device.
|
||||
|
||||
See [INSTALLATION.md](INSTALLATION.md) for detailed setup instructions.
|
||||
For Home Assistant integration, see the separate [ledgrab-haos-integration](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab-haos-integration) repository.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
+78
-145
@@ -1,171 +1,104 @@
|
||||
## v0.3.0 (2026-04-08)
|
||||
# v0.4.0 (2026-04-21)
|
||||
|
||||
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.
|
||||
This release introduces a full **Android TV app** that embeds the Python server via Chaquopy, with boot-time autostart, root-based screen capture, and a watchdog. New device support includes **BLE LED controllers** (SP110E, Triones, Zengge, Govee), **Android USB-serial** Adalight/AmbiLED controllers, and a **Group** device type for combining multiple devices. Metrics now include battery and thermal-zone readings with a dashboard temperature chart. Devices get a new per-provider typed configuration model, and the project has been renamed from `led-grab` to **LedGrab** with the Home Assistant integration split into a separate repository.
|
||||
|
||||
### 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))
|
||||
### Android TV App
|
||||
|
||||
#### 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))
|
||||
- Android TV app embedding Python server via Chaquopy ([8574424](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8574424))
|
||||
- Boot-time autostart, capture watchdog, versionCode derived from git ([b3775b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b3775b2))
|
||||
- Root-based screen capture bypassing MediaProjection ([5fcb9f8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5fcb9f8))
|
||||
|
||||
#### 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))
|
||||
### Devices
|
||||
|
||||
#### 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))
|
||||
- BLE LED controller support — SP110E, Triones, Zengge, Govee ([2b5dac2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2b5dac2))
|
||||
- Android USB-serial support for Adalight/AmbiLED controllers ([7fcb8dd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7fcb8dd))
|
||||
- **Group** device type for combining multiple devices ([4940007](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4940007))
|
||||
- Per-provider typed device configs (phases 1–4) ([d3a6416](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d3a6416))
|
||||
|
||||
#### 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))
|
||||
### Metrics
|
||||
|
||||
#### 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))
|
||||
- Battery + thermal-zone readings with dashboard temperature chart ([ecae05d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ecae05d))
|
||||
|
||||
#### Runtime
|
||||
- Port busy check before starting the server ([ea812bb](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ea812bb))
|
||||
### Sources
|
||||
|
||||
### Bug Fixes
|
||||
- Support nesting for composite color strip sources ([cc9900d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cc9900d))
|
||||
|
||||
- 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))
|
||||
### Project
|
||||
|
||||
- Rename project to **LedGrab**; split Home Assistant integration into a separate repository ([02cd9d5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/02cd9d5))
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- SP110E vendor handshake + Windows/bleak robustness ([45f93fd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/45f93fd))
|
||||
- Coerce `BindableFloat` fps to int when snapshotting scenes ([580bd69](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/580bd69))
|
||||
- Add `autocomplete` attributes to credential inputs ([488df98](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/488df98))
|
||||
- Register pattern-templates API route; responsive toolbar overflow menu ([38f73ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/38f73ba))
|
||||
- HA Light Target cards no longer flicker on every poll cycle ([83ceaed](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/83ceaed))
|
||||
- `EntitySelect` now shows the selected value in weather/processed CSS editors ([d3cd48e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d3cd48e))
|
||||
- Bundle `bettercam`/`dxcam`/`windows-capture` in the Windows installer ([92585e7](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/92585e7))
|
||||
- Launcher: set `TCL_LIBRARY`/`TK_LIBRARY` for embedded Python ([0e09eaf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0e09eaf))
|
||||
- Comprehensive security, stability, and code quality audit ([123da1b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/123da1b))
|
||||
|
||||
---
|
||||
|
||||
### Development / Internal
|
||||
## Development / Internal
|
||||
|
||||
#### 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))
|
||||
### CI/Build
|
||||
|
||||
#### 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))
|
||||
- Android multi-ABI APK pipeline + `pydantic-core` wheel rebuild ([151cea3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/151cea3))
|
||||
- Add Android APK row to release downloads table ([2477e00](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2477e00))
|
||||
- Decouple Android release attach; add `workflow_dispatch` to `release.yml` ([524e422](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/524e422))
|
||||
- Android: fix wheels find-links URL on Linux CI ([5d6310f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5d6310f))
|
||||
- Android: fix missing python symlink parent; restrict to release tags ([7ef17c1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7ef17c1))
|
||||
|
||||
#### Refactoring
|
||||
- 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))
|
||||
### Refactoring
|
||||
|
||||
#### 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))
|
||||
- Route ESP-NOW client through `SerialTransport` ([928d626](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/928d626))
|
||||
- `MetricsProvider` abstraction with Android `/proc` backend ([546b24d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/546b24d))
|
||||
- Move build scripts to `build/` directory ([a0b65e3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a0b65e3))
|
||||
|
||||
#### 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))
|
||||
### Documentation
|
||||
|
||||
- Update TODO and frontend context docs ([e678e55](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e678e55))
|
||||
|
||||
---
|
||||
|
||||
<!-- markdownlint-disable MD033 -->
|
||||
<details>
|
||||
<summary>All Commits (63)</summary>
|
||||
<summary>All Commits</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 |
|
||||
| ---- | ------- | ------ |
|
||||
| [524e422](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/524e422) | ci: decouple android release attach, add workflow_dispatch to release.yml | alexei.dolgolyov |
|
||||
| [5d6310f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5d6310f) | fix(android): make wheels find-links URL work on Linux CI | alexei.dolgolyov |
|
||||
| [7ef17c1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7ef17c1) | ci(android): fix missing python symlink parent, restrict to release tags | alexei.dolgolyov |
|
||||
| [b3775b2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b3775b2) | feat(android): boot-time autostart, capture watchdog, versionCode from git | alexei.dolgolyov |
|
||||
| [45f93fd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/45f93fd) | fix(devices): SP110E vendor handshake + Windows/bleak robustness | alexei.dolgolyov |
|
||||
| [2b5dac2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2b5dac2) | feat(devices): BLE LED controller support (SP110E/Triones/Zengge/Govee) | alexei.dolgolyov |
|
||||
| [d3a6416](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d3a6416) | refactor(devices): per-provider typed configs (phases 1-4) | alexei.dolgolyov |
|
||||
| [123da1b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/123da1b) | fix: comprehensive security, stability, and code quality audit | alexei.dolgolyov |
|
||||
| [5fcb9f8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5fcb9f8) | feat(android): root-based screen capture bypassing MediaProjection | alexei.dolgolyov |
|
||||
| [928d626](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/928d626) | refactor(devices): route ESP-NOW client through SerialTransport | alexei.dolgolyov |
|
||||
| [580bd69](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/580bd69) | fix(scenes): coerce BindableFloat fps to int when snapshotting | alexei.dolgolyov |
|
||||
| [7fcb8dd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7fcb8dd) | feat(devices): Android USB-serial support for Adalight/AmbiLED controllers | alexei.dolgolyov |
|
||||
| [ecae05d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ecae05d) | feat(metrics): battery + thermal-zone readings with dashboard temp chart | alexei.dolgolyov |
|
||||
| [546b24d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/546b24d) | refactor(metrics): MetricsProvider abstraction with Android /proc backend | alexei.dolgolyov |
|
||||
| [488df98](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/488df98) | fix(frontend): add autocomplete attrs to credential inputs | alexei.dolgolyov |
|
||||
| [2477e00](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2477e00) | ci: add Android APK row to release downloads table | alexei.dolgolyov |
|
||||
| [151cea3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/151cea3) | ci: Android multi-ABI APK pipeline + pydantic-core wheel rebuild | alexei.dolgolyov |
|
||||
| [8574424](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8574424) | feat: Android TV app embedding Python server via Chaquopy | alexei.dolgolyov |
|
||||
| [a0b65e3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a0b65e3) | refactor: move build scripts to build/ directory | alexei.dolgolyov |
|
||||
| [02cd9d5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/02cd9d5) | refactor: rename project to LedGrab, split HA integration into separate repo | alexei.dolgolyov |
|
||||
| [38f73ba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/38f73ba) | fix: register pattern-templates API route; add responsive toolbar overflow menu | alexei.dolgolyov |
|
||||
| [e678e55](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e678e55) | docs: update TODO and frontend context docs | alexei.dolgolyov |
|
||||
| [83ceaed](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/83ceaed) | fix: HA Light Target cards flickering on every poll cycle | alexei.dolgolyov |
|
||||
| [d3cd48e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d3cd48e) | fix: EntitySelect not showing selected value in weather/processed CSS editors | alexei.dolgolyov |
|
||||
| [cc9900d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cc9900d) | feat: support nesting for composite color strip sources | alexei.dolgolyov |
|
||||
| [4940007](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4940007) | feat: add Group device type for combining multiple devices | alexei.dolgolyov |
|
||||
| [92585e7](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/92585e7) | fix(build): bundle bettercam/dxcam/windows-capture in installer | alexei.dolgolyov |
|
||||
| [0e09eaf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0e09eaf) | fix(launcher): set TCL_LIBRARY/TK_LIBRARY for embedded Python | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
<!-- markdownlint-enable MD033 -->
|
||||
|
||||
@@ -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,20 @@
|
||||
# TODO
|
||||
|
||||
## IMPORTANT: Remove WLED naming throughout the app
|
||||
|
||||
- [ ] Rename all references to "WLED" in user-facing strings, class names, module names, config keys, file paths, and documentation
|
||||
- [ ] The app is **LedGrab** — not tied to WLED specifically. WLED is just one of many supported output protocols
|
||||
- [ ] Audit: i18n keys, page titles, tray labels, installer text, pyproject.toml description, README, CLAUDE.md, context files, API docs
|
||||
- [ ] Rename `ledgrab` package → decide on new package name (e.g. `ledgrab`)
|
||||
- [ ] Update import paths, entry points, config references, build scripts, Docker, CI/CD
|
||||
- [ ] **Migration required** if renaming storage paths or config keys (see data migration policy in CLAUDE.md)
|
||||
|
||||
---
|
||||
|
||||
## Donation / Open-Source Banner
|
||||
|
||||
- [x] Add a persistent but dismissible banner or notification in the dashboard UI informing users that the project is open-source and under active development, and that donations are highly appreciated
|
||||
- [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
|
||||
@@ -0,0 +1,128 @@
|
||||
# LedGrab TODO
|
||||
|
||||
## BLE LED Controller Support (SP110E / Triones / Zengge / Govee)
|
||||
|
||||
Add support for Bluetooth Low Energy LED controllers driven by mobile apps like "LED Hue", HappyLighting, iLightsIn. Whole-strip ambient-color output only — these protocols don't support per-pixel streaming.
|
||||
|
||||
- [x] Add `bleak>=0.22` as optional extra `[ble]` in `server/pyproject.toml` (desktop-only, NOT in android `build.gradle.kts`)
|
||||
- [x] `core/devices/ble_transport.py` — bleak wrapper: scan, connect, write-with/without-response
|
||||
- [x] `core/devices/ble_protocols/` package
|
||||
- [x] `__init__.py` — `BLEProtocol` dataclass + registry (family → encoder)
|
||||
- [x] `sp110e.py` — SP110E / SP108E (service FFE0, char FFE1, `RR GG BB 00 1E` static-color frame)
|
||||
- [x] `triones.py` — Triones / HappyLighting / LEDnet (service FFE5, char FFE9, `7E 07 05 03 RR GG BB 10 EF`)
|
||||
- [x] `zengge.py` — Zengge / iLightsIn (service FFE0, framing `56 RR GG BB 00 F0 AA`)
|
||||
- [x] `govee.py` — Govee unencrypted framed protocol (AES keyed variants — marked experimental)
|
||||
- [x] `core/devices/ble_client.py` — unified `BLEClient(LEDClient)` — picks protocol by `ble_family`, averages strip → one color, drops duplicate frames, rate-limits to BLE connection interval
|
||||
- [x] `core/devices/ble_provider.py` — `BLEDeviceProvider` + discovery via `BleakScanner`
|
||||
- [x] Register in `core/devices/led_client.py::_register_builtin_providers` (guarded `try/except ImportError`)
|
||||
- [x] Storage: `ble_family`, `ble_govee_key` fields threaded through `Device.__init__`/`to_dict`/`from_dict`/`_UPDATABLE_FIELDS`/`create_device`
|
||||
- [x] Schemas: BLE fields on `DeviceCreate`, `DeviceUpdate`, `DeviceResponse`
|
||||
- [x] Routes: BLE fields propagated through create/update in `api/routes/devices.py` + `_device_to_response`
|
||||
- [x] ProcessorManager: `ble_family`/`ble_govee_key` added to `_DEVICE_FIELD_DEFAULTS` and `DeviceInfo`; passed through `wled_target_processor.py` and `group_client.py` to `create_led_client`
|
||||
- [x] Tests: 21 protocol encoder unit tests + 16 BLEClient fake-transport tests — all passing, 814 total tests still green
|
||||
- [x] Frontend: BLE option in the device type picker with a bluetooth Lucide icon; add-device modal shows a 4-option `IconSelect` for protocol family (SP110E / Triones / Zengge / Govee) with a Govee-only AES key field that auto-hides for the other three families; URL label/placeholder/hint adapt to `ble://<address>` pattern; submit payload carries `ble_family` (+ optional `ble_govee_key`); clone flow pre-fills family and key; modal dirty-check snapshots the new fields; network scan button now also discovers BLE peripherals via the existing `/api/v1/devices/discover?device_type=ble` endpoint
|
||||
- [x] Frontend: `isBleDevice` helper in `core/api.ts`; `ICON_BLUETOOTH` + `ICON_LIGHTBULB` constants in `core/icons.ts`; `bluetooth` path in `core/icon-paths.ts`; i18n keys in `en.json` / `ru.json` / `zh.json`; TypeScript compiles; esbuild bundle rebuilt
|
||||
- [x] Android BLE via Kotlin bridge — `BleBridge.kt` singleton (scan/connect/write/disconnect); `android_ble_transport.py` Python wrapper; `make_transport()` factory in `ble_transport.py` auto-selects backend; `BleBridge.init()` called from `LedGrabApp.onCreate`; BLE permissions in `AndroidManifest.xml`
|
||||
- [x] Govee per-model AES key — `_encrypt_govee_frame()` in `ble_client.py` uses AES-128-ECB from `cryptography`; key validated on `BLEClient` construction; applied to both `send_pixels` and `set_power`; 8 new AES unit tests
|
||||
|
||||
## Android — Restore Multi-ABI Wheels
|
||||
|
||||
During emulator testing, we switched the build to **x86 only** (see `android/app/build.gradle.kts` `abiFilters`) to avoid having to keep the arm64-v8a / x86_64 pydantic-core wheels current. Before shipping, restore all three ABIs:
|
||||
|
||||
- [x] Rebuild `pydantic-core` wheels for all three ABIs with the current SOABI + libpython linking settings (`android/build-scripts/build-pydantic-core.sh` — now supports `arm64`, `x86_64`, `x86` args; defaults to all three).
|
||||
- [x] Verify wheels: all three now list `libpython3.11.so` in `NEEDED` (`llvm-readelf -d`), automated in the build script.
|
||||
- [x] Restored `abiFilters += listOf("arm64-v8a", "x86_64", "x86")` in `build.gradle.kts`. Multi-ABI debug APK builds cleanly (~99 MB).
|
||||
- [ ] Re-test on real ARM64 Android TV hardware (still pending — only emulator-verified build).
|
||||
|
||||
Build cache + scripts live in `android/build-scripts/` and `android/.build-cache/` (junction host + sysconfigdata for each ABI).
|
||||
|
||||
## Android CI Pipeline
|
||||
|
||||
Build the Android APK automatically on push/tag.
|
||||
|
||||
- [x] Generate Gradle wrapper (`gradlew`) and commit it
|
||||
- [x] Create CI workflow (`.gitea/workflows/build-android.yml`)
|
||||
- JDK 17 + Android SDK + NDK setup
|
||||
- Python 3.11 for Chaquopy build
|
||||
- Recreate the directory junction via `ln -s` on Linux CI
|
||||
- `./gradlew assembleDebug` on master push, `assembleRelease` on `v*` tags (if signing secrets set)
|
||||
- Uploads APK as CI artifact; attaches to Gitea release on tag push
|
||||
- [x] Commit pre-built pydantic-core wheels to `android/wheels/` (arm64, x86, x86_64)
|
||||
- [x] APK signing for release builds — conditional signing config reads keystore from env vars (`ANDROID_KEYSTORE_PATH/_PASSWORD/_ALIAS/_KEY_PASSWORD`), falls back to debug signing locally
|
||||
- [ ] Provision a real keystore and add the four CI secrets:
|
||||
- `ANDROID_KEYSTORE_BASE64` (base64-encoded .jks)
|
||||
- `ANDROID_KEYSTORE_PASSWORD`
|
||||
- `ANDROID_KEY_ALIAS`
|
||||
- `ANDROID_KEY_PASSWORD`
|
||||
- [ ] Add `LedGrab-{tag}-android-release.apk` row to the release description table in `.gitea/workflows/release.yml` → `create-release` job
|
||||
- [ ] Verify the CI workflow passes end-to-end with the now-restored multi-ABI build (larger APK, longer Android build step)
|
||||
|
||||
## Android Root Capture (No Permission Dialog, No System Indicator)
|
||||
|
||||
MediaProjection shows a mandatory system overlay/indicator while capturing — unavoidable on stock Android. Many cheap Android TV boxes ship pre-rooted, so an alternative root-only path gives much better UX.
|
||||
|
||||
- [x] Root detection — `Root.kt` checks common `su` binary paths and, on demand, runs `su -c id` to actually prove UID 0. First call triggers Magisk's grant dialog; grant is cached per session. Exposed to Python via Chaquopy.
|
||||
- [x] `RootScreenrecord.kt` — spawns `su -c screenrecord --output-format=h264 --size=WxH -`, feeds the H.264 stdout through a MediaCodec decoder whose output Surface is wired into an ImageReader (RGBA_8888, row-stride-aware). Decoded frames reach the Python pipeline via `PythonBridge.pushRootFrame`.
|
||||
- [x] Python-side `RootScreenrecordEngine` (`core/capture_engines/root_screenrecord_engine.py`) mirrors `MediaProjectionEngine` with `ENGINE_PRIORITY=110` (> MediaProjection's 100) so the factory picks it automatically when available.
|
||||
- [x] `MainActivity` tries `Root.requestGrant()` before launching the MediaProjection consent flow — on rooted devices the consent dialog is skipped entirely. `CaptureService` has a `createRootIntent()` entry point that bypasses the MediaProjection path.
|
||||
- [x] Fallback: if `Root.requestGrant()` returns false (no root, user denied, or `su` timeout) the existing MediaProjection flow runs unchanged.
|
||||
- [ ] Real-hardware test pending — need to verify on the user's Magisk'd TV box that: (1) grant dialog appears once, (2) frames actually flow through MediaCodec without the Android 14 capture indicator showing, (3) stop/start cycle terminates the `su` process cleanly.
|
||||
- [WONTDO] `SurfaceControl.screenshot()` via reflection — renamed/moved across API 28/29/30/33, hidden-API blocklist varies by release, even rooted apps hit it; days of maintenance for a marginal latency win over the screenrecord path. Not worth it.
|
||||
- [WONTDO] `adb screencap` fallback — full-PNG-per-frame pipeline is slower than MediaProjection, no value as a last resort.
|
||||
|
||||
Known projects using the screenrecord approach for reference: scrcpy (over ADB), scrcpy-hidden-api, shizuku.
|
||||
|
||||
## Android Autostart on Boot
|
||||
|
||||
Boot-time, zero-interaction startup so LedGrab always has display capture and control on rooted TV boxes.
|
||||
|
||||
- [x] Manifest: declare `RECEIVE_BOOT_COMPLETED`, `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`, `WAKE_LOCK`; register `.BootReceiver` for `BOOT_COMPLETED` / `LOCKED_BOOT_COMPLETED` / `MY_PACKAGE_REPLACED`.
|
||||
- [x] `BootReceiver.kt` — gated by `AutostartPrefs` + `Root.looksRooted()`; dispatches `CaptureService.createRootIntent()` via `ContextCompat.startForegroundService`. Unrooted devices are a no-op because MediaProjection consent cannot be bypassed silently.
|
||||
- [x] `AutostartPrefs.kt` — thin SharedPreferences wrapper, defaults to enabled. Shown as a CheckBox on the stopped panel; greyed out on unrooted devices.
|
||||
- [x] `CaptureService` returns `START_REDELIVER_INTENT` for root-mode intents so the OS can cleanly restart the service after being killed (token-free path). MediaProjection-mode keeps `START_NOT_STICKY` — restart is pointless with a dead consent token.
|
||||
- [x] `isRunning` race: moved assignment to after `startForeground` succeeds, resets on exception; `onStartCommand` wraps `startForeground` in try/catch and stops the service cleanly if the FG transition fails.
|
||||
- [x] Root-capture watchdog: coroutine on `serviceScope` checks `RootScreenrecord.framesDelivered` every 5s after a 5s grace. Respawns the pipeline (reusing the existing Python bridge) on stall, caps at 3 consecutive restarts before giving up.
|
||||
- [x] `RootScreenrecord.framesDelivered` exposed as a property backed by `AtomicInteger` (was `@Volatile var framesDelivered = 0` with non-atomic `+= 1`).
|
||||
- [x] `ScreenCapture` accepts `onProjectionStopped` lambda — `MediaProjection.Callback.onStop` now tears the whole service down instead of leaving a stale FG notification.
|
||||
- [x] `MainActivity` wires the autostart toggle to `AutostartPrefs`; enabling it prompts `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` so Doze doesn't kill the FG service on phones.
|
||||
- [x] `versionCode` derived from `git rev-list --count HEAD` (or `ANDROID_VERSION_CODE` env var in CI). Was stuck at 1 — sideload updates were silently refusing to install.
|
||||
- [ ] Real-hardware test pending — need to verify on the user's Magisk'd TV box that: (1) boot-time autostart dispatches the service without UI, (2) capture indicator still absent under root mode post-reboot, (3) watchdog respawns the pipeline when `screenrecord` is externally killed, (4) sideload upgrade installs cleanly after the versionCode bump.
|
||||
- [ ] Optional follow-up: "kiosk" mode — add `<category android:name="android.intent.category.HOME" />` to `MainActivity` so power users can set LedGrab as the default TV launcher for truly always-running behavior.
|
||||
|
||||
## Android USB Serial Support
|
||||
|
||||
Drive USB LED controllers (APA102, WS2812) connected directly to the Android TV box via USB-to-serial adapters.
|
||||
|
||||
- [x] Added `com.github.mik3y:usb-serial-for-android:3.8.1` (via JitPack) to `android/app/build.gradle.kts`.
|
||||
- [x] Kotlin `UsbSerialBridge` singleton (`android/app/src/main/java/com/ledgrab/android/UsbSerialBridge.kt`) — exposes `listDevices()`, `open(vid, pid, serial, baud)`, `write(handle, ByteArray)`, `close(handle)`. Permission request fires automatically from `open()` when the user hasn't granted access yet. Handles are opaque integers, port map is synchronized, so Python threads can share one bridge.
|
||||
- [x] Python `AndroidSerialTransport` in `server/src/ledgrab/core/devices/android_serial_transport.py` drives the bridge through Chaquopy. `SerialTransport` Protocol + `PySerialTransport` + `list_serial_ports()` factory live in `serial_transport.py`; `AdalightClient` and `SerialDeviceProvider` now go through the abstraction instead of importing `pyserial` directly.
|
||||
- [x] URL scheme extended: `usb:VID:PID[:serial][@baud]` on Android alongside the existing `COM3[:baud]` / `/dev/ttyUSB0[:baud]` desktop paths.
|
||||
- [x] App initializes the bridge on startup (`LedGrabApp.onCreate` → `UsbSerialBridge.init(this)`); manifest declares `uses-feature android.hardware.usb.host`.
|
||||
- [ ] Real-device test pending — no USB-serial hardware on dev machine. Need to verify on a TV box with CH340, CP2102, or FTDI adapter.
|
||||
- [ ] Document supported USB LED controllers in README (once real-device test passes).
|
||||
- [ ] Optional: auto-launch the app when a known USB-serial adapter is plugged in (intent-filter on `USB_DEVICE_ATTACHED` + `res/xml/device_filter.xml`). Skipped in v1 — users can just open LedGrab and hit "Discover".
|
||||
- [x] ESP-NOW client (`espnow_client.py` / `espnow_provider.py`) now routes through `SerialTransport` — `open_transport()` for the gateway serial link, `list_serial_ports()` + `port_exists()` for discovery/validation. Works transparently with `usb:VID:PID` URLs on Android. (Gateway protocol is write-only, so no `read()` extension was needed after all.)
|
||||
|
||||
## Performance Metrics Abstraction
|
||||
|
||||
- [x] `MetricsProvider` protocol + dataclass DTOs (`MemorySnapshot`, `ProcessSnapshot`) live in `server/src/ledgrab/utils/metrics/types.py`. Each provider has its own module: `psutil_provider.py`, `null_provider.py`, `android_provider.py`.
|
||||
- [x] Factory `get_metrics_provider()` in `utils/metrics/__init__.py` selects Android → psutil → Null. `psutil` import is now confined to one place.
|
||||
- [x] `api/routes/system.py` and `core/processing/metrics_history.py` use the provider; no more `if psutil is not None` guards in the hot paths.
|
||||
- [x] Android `/proc`-backed provider implemented (`/proc/stat`, `/proc/meminfo`, `/proc/self/stat`, `/proc/self/status`). Carries previous-sample state for delta-based CPU%; degrades to zeros if any `/proc` file is locked down. 12 unit tests cover both desktop and Android paths.
|
||||
|
||||
## Android Performance Metrics — Future Enhancements
|
||||
|
||||
Beyond the `/proc`-based AndroidMetricsProvider that's now in place:
|
||||
|
||||
- [x] Device battery + thermal-zone readings (`/sys/class/power_supply/battery/{capacity,temp}`, `/sys/class/thermal/thermal_zone*/temp` filtered by zone type). Surfaced through `MetricsProvider.thermals()`, `PerformanceResponse.{cpu_temp_c,battery_percent,battery_temp_c}`, the metrics-history snapshot, and a new dashboard temperature chart that hides itself when the backend reports null. GPU card now hides (no "unavailable" placeholder) when no GPU is present.
|
||||
- [WONTDO] Optional: app-specific memory via `Debug.getMemoryInfo()` through a Kotlin → Python Chaquopy bridge (more accurate than `VmRSS` for split-app-process accounting)
|
||||
- [WONTDO] Optional: GPU usage via `/sys/class/kgsl/kgsl-3d0/gpubusy` on Adreno, Mali-specific paths for Mali GPUs
|
||||
|
||||
## Refactor: Per-Provider Device Configs
|
||||
|
||||
Replace flat `DeviceInfo` + `**kwargs` provider contract with a discriminated union of typed per-provider config dataclasses. Full plan: [docs/plans/device-typed-configs.md](docs/plans/device-typed-configs.md).
|
||||
|
||||
- [x] Phase 1 — `DeviceConfig` hierarchy + `Device.to_config()` (non-breaking, additive only)
|
||||
- [x] Phases 2+3 — narrow `LEDDeviceProvider.create_client` to typed configs; migrate 3 call sites; delete `DeviceInfo` + `_get_device_info` + `_DEVICE_FIELD_DEFAULTS` (single PR)
|
||||
- [x] Phase 4 — migrate `tests/test_group_device.py` to `GroupConfig`/`ProviderDeps`; remove legacy `GroupLEDClient` init path; 47-test config suite with 100% coverage on `device_config.py`
|
||||
- [ ] Phase 5 (separate PR, optional) — Pydantic v2 discriminated union in `api/schemas/devices.py`; scope frontend POST/PATCH payloads by `device_type`
|
||||
@@ -0,0 +1,20 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
.DS_Store
|
||||
/build
|
||||
/app/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
|
||||
# Chaquopy build cache
|
||||
.build-cache/
|
||||
|
||||
# Python source junction (points at ../server/src/ledgrab — do not commit)
|
||||
/app/src/main/python/ledgrab
|
||||
|
||||
# Signing keystore decoded from CI secrets
|
||||
/keystore/
|
||||
@@ -0,0 +1,183 @@
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("com.chaquo.python")
|
||||
}
|
||||
|
||||
// Derive versionCode from git commit count so sideload/Play upgrades
|
||||
// always see a strictly-greater value without anyone remembering to
|
||||
// bump by hand. ANDROID_VERSION_CODE env var wins for CI pipelines
|
||||
// that prefer explicit values; fallback is `1` when neither is
|
||||
// available (e.g. building from a tarball with no .git).
|
||||
val ledgrabVersionCode: Int = run {
|
||||
System.getenv("ANDROID_VERSION_CODE")?.toIntOrNull()?.let { return@run it }
|
||||
try {
|
||||
val process = ProcessBuilder("git", "rev-list", "--count", "HEAD")
|
||||
.directory(rootDir)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
if (process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0) {
|
||||
process.inputStream.bufferedReader().readText().trim().toIntOrNull() ?: 1
|
||||
} else {
|
||||
1
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.ledgrab.android"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.ledgrab.android"
|
||||
minSdk = 24 // Android 7.0 — covers nearly all TV boxes
|
||||
targetSdk = 34
|
||||
// Derived from git commit count (or ANDROID_VERSION_CODE env var
|
||||
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
|
||||
// sideload updates silently refused to install.
|
||||
versionCode = ledgrabVersionCode
|
||||
versionName = "0.4.0"
|
||||
|
||||
ndk {
|
||||
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
|
||||
// emulators), x86 (legacy emulators). Wheels in android/wheels/
|
||||
// must be kept in sync — see build-scripts/build-pydantic-core.sh.
|
||||
abiFilters += listOf("arm64-v8a", "x86_64", "x86")
|
||||
}
|
||||
}
|
||||
|
||||
// Per-ABI APK splits — reduces download size by ~60% vs universal APK.
|
||||
// Each split contains only one native ABI's shared libraries + wheels.
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
reset()
|
||||
include("arm64-v8a", "x86_64", "x86")
|
||||
isUniversalApk = true // also produce a fat APK for sideloading
|
||||
}
|
||||
}
|
||||
|
||||
// Signing config from env vars (CI) — only registered when all four are set.
|
||||
// Local release builds fall back to the debug signing config.
|
||||
val ciKeystorePath = System.getenv("ANDROID_KEYSTORE_PATH")
|
||||
val ciKeystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||
val ciKeyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
||||
val ciKeyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
||||
val hasCiSigning = listOf(ciKeystorePath, ciKeystorePassword, ciKeyAlias, ciKeyPassword)
|
||||
.all { !it.isNullOrBlank() } && file(ciKeystorePath!!).exists()
|
||||
|
||||
signingConfigs {
|
||||
if (hasCiSigning) {
|
||||
create("release") {
|
||||
storeFile = file(ciKeystorePath!!)
|
||||
storePassword = ciKeystorePassword
|
||||
keyAlias = ciKeyAlias
|
||||
keyPassword = ciKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO(minify): keep R8 disabled until Chaquopy reflection is
|
||||
// verified end-to-end. Chaquopy resolves Kotlin classes & static
|
||||
// methods (PythonBridge, UsbSerialBridge, Root) by name from
|
||||
// Python via PyObject — silent stripping breaks the app at
|
||||
// runtime, after release. proguard-rules.pro contains keep
|
||||
// rules covering the known entry points, but until we have
|
||||
// a release smoke test that exercises every PyObject path we
|
||||
// do NOT ship a minified release.
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
signingConfig = if (hasCiSigning) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
chaquopy {
|
||||
defaultConfig {
|
||||
version = "3.11"
|
||||
|
||||
pip {
|
||||
// Pre-built wheels directory (for pydantic-core etc.) as an
|
||||
// absolute file:// URI with forward slashes. Pip requires
|
||||
// exactly three slashes after `file:` for an empty-host local
|
||||
// path; the path-normalization differs by platform:
|
||||
// Windows: absolute path is "C:/..." → prefix "file:///"
|
||||
// Linux: absolute path is "/..." → prefix "file://"
|
||||
// Using a fixed "file:///" prefix on both made CI produce
|
||||
// "file:////workspace/…", which pip rejects as non-local.
|
||||
val wheelsPath = rootDir.absolutePath.replace("\\", "/")
|
||||
val wheelsUrlPrefix = if (wheelsPath.startsWith("/")) "file://" else "file:///"
|
||||
options("--find-links", "$wheelsUrlPrefix$wheelsPath/wheels/")
|
||||
|
||||
// ── Android-compatible dependencies ─────────────────
|
||||
// Listed explicitly because pyproject.toml includes
|
||||
// desktop-only packages with no Android wheels.
|
||||
// See CLAUDE.md "Android Dependency Sync" for policy.
|
||||
install("fastapi")
|
||||
install("uvicorn") // without [standard] — no uvloop/httptools
|
||||
install("httpx")
|
||||
install("numpy")
|
||||
install("pydantic") // needs pydantic-core wheel in wheels/
|
||||
install("pydantic-settings")
|
||||
install("PyYAML")
|
||||
install("structlog")
|
||||
install("python-json-logger")
|
||||
install("python-dateutil")
|
||||
install("python-multipart")
|
||||
install("jinja2")
|
||||
install("zeroconf")
|
||||
install("aiomqtt")
|
||||
install("openrgb-python")
|
||||
// opencv-python-headless: no cp311 Android wheel on Chaquopy.
|
||||
// LedGrab's cv2 usage is guarded with try/except ImportError
|
||||
// and falls back to numpy/Pillow alternatives on Android.
|
||||
install("Pillow")
|
||||
install("websockets")
|
||||
install("cryptography") // AES-GCM secret-box for HA/MQTT credentials
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LedGrab Python source is included via a directory junction:
|
||||
// android/app/src/main/python/ledgrab -> server/src/ledgrab
|
||||
// This is the standard Chaquopy way to include local Python packages.
|
||||
// Create the junction (run from repo root, no admin needed):
|
||||
// cmd /c "mklink /J android\app\src\main\python\ledgrab server\src\ledgrab"
|
||||
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.leanback:leanback:1.0.0")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-service:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||
// QR code generation for displaying server URL on TV
|
||||
implementation("com.google.zxing:core:3.5.3")
|
||||
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
|
||||
// driving Adalight/AmbiLED controllers plugged into Android TV boxes.
|
||||
implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
|
||||
}
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
# LedGrab ProGuard / R8 rules.
|
||||
#
|
||||
# IMPORTANT: Chaquopy resolves Java/Kotlin classes and static methods by
|
||||
# name from Python (e.g. UsbSerialBridge.INSTANCE.listDevices()) via
|
||||
# reflection. Anything reachable through PyObject must be kept by name
|
||||
# or the release build will throw NoSuchMethod / ClassNotFound at
|
||||
# runtime — silently, only on the user's device.
|
||||
#
|
||||
# Keep ALL of com.ledgrab.android.* members for safety. The app is
|
||||
# small enough that the size win from stripping these isn't worth the
|
||||
# fragility.
|
||||
-keep class com.ledgrab.android.** { *; }
|
||||
|
||||
# Chaquopy runtime itself.
|
||||
-keep class com.chaquo.python.** { *; }
|
||||
-dontwarn com.chaquo.python.**
|
||||
|
||||
# usb-serial-for-android — driver classes are loaded via the prober's
|
||||
# default device-id list, which uses reflection in some chip drivers.
|
||||
-keep class com.hoho.android.usbserial.driver.** { *; }
|
||||
-dontwarn com.hoho.android.usbserial.**
|
||||
|
||||
# Kotlin coroutines — keep the debug agent off and the metadata intact.
|
||||
-dontwarn kotlinx.coroutines.**
|
||||
|
||||
# Standard Android best-practice keeps.
|
||||
-keepattributes Signature, InnerClasses, EnclosingMethod, *Annotation*
|
||||
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- BLE scanning and connecting — API ≥31 uses granular permissions;
|
||||
older releases need BLUETOOTH + ACCESS_FINE_LOCATION for scanning.
|
||||
neverForLocation avoids the location permission dialog on API 31+. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<!-- BLE hardware — required=false so non-BT boxes still install. -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.bluetooth_le"
|
||||
android:required="false" />
|
||||
|
||||
<!-- Network access for WLED HTTP/UDP, web UI, MQTT -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<!-- MediaProjection requires a foreground service -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
|
||||
<!-- POST_NOTIFICATIONS for Android 13+ foreground service notification -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Autostart on boot — BootReceiver spawns CaptureService in root
|
||||
mode so capture resumes without the user touching the remote. -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<!-- Exempt from Doze/App Standby so the FG service isn't killed
|
||||
overnight on phones; essentially a no-op on TV boxes. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<!-- Optional wake lock for sustained-performance boxes that aggressively
|
||||
sleep the CPU with the display off. -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- Android TV declarations -->
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="true" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
|
||||
<!-- USB host — for USB-to-TTL adapters driving Adalight/AmbiLED
|
||||
controllers. required=false so phones without USB host still install. -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.usb.host"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".LedGrabApp"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:banner="@drawable/ic_launcher"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@style/Theme.LedGrab">
|
||||
|
||||
<!-- TV launcher activity -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Foreground service for screen capture + Python server -->
|
||||
<service
|
||||
android:name=".CaptureService"
|
||||
android:foregroundServiceType="mediaProjection"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Autostart — fires on device boot (and package replace).
|
||||
On rooted devices, launches CaptureService directly so capture
|
||||
resumes without the user tapping Start. Unrooted devices are
|
||||
no-op because MediaProjection consent cannot be bypassed. -->
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:exported="true"
|
||||
android:enabled="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* Thin SharedPreferences wrapper for the boot-autostart toggle.
|
||||
*
|
||||
* Default is `true`: [BootReceiver] still bails out when the device
|
||||
* isn't rooted, so a true default is safe — it only takes effect on
|
||||
* boxes where we can actually honor it silently.
|
||||
*/
|
||||
class AutostartPrefs(context: Context) {
|
||||
|
||||
private val prefs = context.applicationContext
|
||||
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
var isEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED)
|
||||
set(value) { prefs.edit().putBoolean(KEY_ENABLED, value).apply() }
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "ledgrab_autostart"
|
||||
private const val KEY_ENABLED = "autostart_enabled"
|
||||
private const val DEFAULT_ENABLED = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import java.util.Collections
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
|
||||
/**
|
||||
* Android BLE bridge exposed to the Python server via Chaquopy.
|
||||
*
|
||||
* Wraps the Android BluetoothGatt / BluetoothLeScanner APIs into
|
||||
* synchronous, blocking calls that can be safely invoked from
|
||||
* a Python thread (Chaquopy proxy threads are real OS threads).
|
||||
*
|
||||
* All GATT callbacks run on a private [HandlerThread] so they don't
|
||||
* block the main looper. [runBlocking] is used to bridge callback
|
||||
* completions back to the calling Python thread.
|
||||
*
|
||||
* Python callers access the singleton via
|
||||
* `BleBridge.INSTANCE.scan()` etc. — see
|
||||
* `server/src/ledgrab/core/devices/android_ble_transport.py`.
|
||||
*/
|
||||
object BleBridge {
|
||||
private const val TAG = "BleBridge"
|
||||
private const val CONNECT_TIMEOUT_MS = 18_000L // connect + service discovery
|
||||
private const val WRITE_TIMEOUT_MS = 5_000L
|
||||
|
||||
@Volatile private var appContext: Context? = null
|
||||
|
||||
private val handleSeq = AtomicInteger(1)
|
||||
|
||||
// Dedicated looper thread so BLE callbacks don't land on the main thread.
|
||||
private val bleHandlerThread = HandlerThread("LedGrab-BLE").also { it.start() }
|
||||
private val bleHandler = Handler(bleHandlerThread.looper)
|
||||
|
||||
private data class GattHandle(
|
||||
val gatt: BluetoothGatt,
|
||||
val writeChar: BluetoothGattCharacteristic,
|
||||
)
|
||||
|
||||
private val handles = ConcurrentHashMap<Int, GattHandle>()
|
||||
|
||||
// Write completion futures, keyed by handle. Only populated for
|
||||
// WRITE_TYPE_DEFAULT (with-response) writes.
|
||||
private val pendingWrites = ConcurrentHashMap<Int, CompletableDeferred<Boolean>>()
|
||||
|
||||
/** Called once from [LedGrabApp.onCreate] to bind the application context. */
|
||||
@JvmStatic
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
private fun ctx(): Context =
|
||||
appContext ?: error("BleBridge.init() not called — app context unavailable")
|
||||
|
||||
private fun adapter() =
|
||||
ctx().getSystemService(BluetoothManager::class.java)?.adapter
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan for BLE peripherals for [timeoutMs] milliseconds.
|
||||
*
|
||||
* Returns a list of `"address|name|rssi"` strings. Addresses are
|
||||
* deduplicated — only the last-seen RSSI for each address is kept.
|
||||
* Returns an empty list if Bluetooth is off or the permission is denied.
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun scan(timeoutMs: Long = 4_000L): List<String> {
|
||||
val adapter = adapter() ?: return emptyList()
|
||||
if (!adapter.isEnabled) return emptyList()
|
||||
val scanner = try { adapter.bluetoothLeScanner } catch (_: SecurityException) { null }
|
||||
?: return emptyList()
|
||||
|
||||
val seen = Collections.synchronizedMap(LinkedHashMap<String, String>())
|
||||
val callback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val address = result.device.address ?: return
|
||||
val name = result.scanRecord?.deviceName ?: result.device.name ?: ""
|
||||
seen[address] = "$address|$name|${result.rssi}"
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
Log.w(TAG, "BLE scan failed with error $errorCode")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
bleHandler.post { scanner.startScan(callback) }
|
||||
Thread.sleep(timeoutMs)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
} finally {
|
||||
try { bleHandler.post { scanner.stopScan(callback) } } catch (_: SecurityException) {}
|
||||
}
|
||||
return seen.values.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the BLE peripheral at [address] and locate the GATT
|
||||
* characteristic identified by [writeCharUuid] across all services.
|
||||
*
|
||||
* Blocks until connected + services discovered, or returns -1 on failure.
|
||||
* The returned integer is an opaque handle passed to [write]/[disconnect].
|
||||
*/
|
||||
@JvmStatic
|
||||
fun connect(address: String, writeCharUuid: String): Int {
|
||||
val adapter = adapter() ?: return -1
|
||||
val device = try { adapter.getRemoteDevice(address) } catch (e: Exception) {
|
||||
Log.e(TAG, "Invalid BLE address '$address': ${e.message}")
|
||||
return -1
|
||||
}
|
||||
|
||||
val readyDeferred = CompletableDeferred<Boolean>()
|
||||
|
||||
val callback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
when {
|
||||
newState == BluetoothProfile.STATE_CONNECTED
|
||||
&& status == BluetoothGatt.GATT_SUCCESS -> {
|
||||
Log.d(TAG, "GATT connected to $address, discovering services")
|
||||
gatt.discoverServices()
|
||||
}
|
||||
newState == BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
Log.w(TAG, "GATT disconnected from $address (status=$status)")
|
||||
readyDeferred.complete(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||
readyDeferred.complete(status == BluetoothGatt.GATT_SUCCESS)
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: Int,
|
||||
) {
|
||||
val h = handles.entries.firstOrNull { it.value.gatt === gatt }?.key ?: return
|
||||
pendingWrites.remove(h)?.complete(status == BluetoothGatt.GATT_SUCCESS)
|
||||
}
|
||||
}
|
||||
|
||||
val gatt: BluetoothGatt = try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
device.connectGatt(
|
||||
ctx(), false, callback,
|
||||
android.bluetooth.BluetoothDevice.TRANSPORT_LE,
|
||||
android.bluetooth.BluetoothDevice.PHY_LE_1M_MASK,
|
||||
bleHandler,
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
device.connectGatt(
|
||||
ctx(), false, callback,
|
||||
android.bluetooth.BluetoothDevice.TRANSPORT_LE,
|
||||
)
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "BLUETOOTH_CONNECT permission denied for $address", e)
|
||||
return -1
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "connectGatt failed for $address", e)
|
||||
return -1
|
||||
}
|
||||
|
||||
val ready = try {
|
||||
runBlocking { withTimeout(CONNECT_TIMEOUT_MS) { readyDeferred.await() } }
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
Log.e(TAG, "BLE connect+discovery timed out for $address")
|
||||
runCatching { gatt.close() }
|
||||
return -1
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
runCatching { gatt.close() }
|
||||
return -1
|
||||
}
|
||||
|
||||
val charUuid = try { UUID.fromString(writeCharUuid) } catch (e: Exception) {
|
||||
Log.e(TAG, "Invalid characteristic UUID '$writeCharUuid'")
|
||||
gatt.disconnect(); gatt.close()
|
||||
return -1
|
||||
}
|
||||
val writeChar = gatt.services.flatMap { it.characteristics }
|
||||
.firstOrNull { it.uuid == charUuid }
|
||||
|
||||
if (writeChar == null) {
|
||||
Log.e(TAG, "Characteristic $writeCharUuid not found on $address")
|
||||
gatt.disconnect(); gatt.close()
|
||||
return -1
|
||||
}
|
||||
|
||||
val handle = handleSeq.getAndIncrement()
|
||||
handles[handle] = GattHandle(gatt, writeChar)
|
||||
Log.i(TAG, "BLE connected: address=$address char=$writeCharUuid handle=$handle")
|
||||
return handle
|
||||
}
|
||||
|
||||
/**
|
||||
* Write [data] to the characteristic associated with [handle].
|
||||
*
|
||||
* [withResponse] controls the GATT write type:
|
||||
* - `true` → Write Request (waits for device ACK, slower but reliable)
|
||||
* - `false` → Write Command (fire-and-forget, faster, used by SP110E/Triones/Zengge)
|
||||
*
|
||||
* Returns `true` on success, `false` on any error.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun write(handle: Int, data: ByteArray, withResponse: Boolean): Boolean {
|
||||
val entry = handles[handle] ?: return false
|
||||
val gatt = entry.gatt
|
||||
val char = entry.writeChar
|
||||
val writeType = if (withResponse)
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
else
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
|
||||
return if (withResponse) {
|
||||
val deferred = CompletableDeferred<Boolean>()
|
||||
pendingWrites[handle] = deferred
|
||||
|
||||
val initiated = gattWrite(gatt, char, data, writeType)
|
||||
if (!initiated) {
|
||||
pendingWrites.remove(handle)
|
||||
return false
|
||||
}
|
||||
try {
|
||||
runBlocking { withTimeout(WRITE_TIMEOUT_MS) { deferred.await() } }
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
pendingWrites.remove(handle)
|
||||
Log.w(TAG, "BLE write-with-response timed out on handle $handle")
|
||||
false
|
||||
}
|
||||
} else {
|
||||
gattWrite(gatt, char, data, writeType)
|
||||
}
|
||||
}
|
||||
|
||||
/** Disconnect and close the GATT connection for [handle]. */
|
||||
@JvmStatic
|
||||
fun disconnect(handle: Int) {
|
||||
val entry = handles.remove(handle) ?: return
|
||||
pendingWrites.remove(handle)?.complete(false)
|
||||
runCatching {
|
||||
entry.gatt.disconnect()
|
||||
entry.gatt.close()
|
||||
}.onFailure { Log.w(TAG, "BLE disconnect error for handle $handle: ${it.message}") }
|
||||
Log.i(TAG, "BLE disconnected handle=$handle")
|
||||
}
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private fun gattWrite(
|
||||
gatt: BluetoothGatt,
|
||||
char: BluetoothGattCharacteristic,
|
||||
data: ByteArray,
|
||||
writeType: Int,
|
||||
): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
gatt.writeCharacteristic(char, data, writeType) ==
|
||||
android.bluetooth.BluetoothStatusCodes.SUCCESS
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
char.writeType = writeType
|
||||
@Suppress("DEPRECATION")
|
||||
char.value = data
|
||||
@Suppress("DEPRECATION")
|
||||
gatt.writeCharacteristic(char)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* Starts the LedGrab capture service automatically on device boot and
|
||||
* after the app is updated.
|
||||
*
|
||||
* Autostart is best-effort and deliberately limited:
|
||||
* - Requires root. MediaProjection consent cannot be granted silently,
|
||||
* so we can't resume capture on unrooted boxes without the user
|
||||
* walking up to the TV and tapping Start.
|
||||
* - Gated behind [AutostartPrefs] so the user can opt out.
|
||||
*
|
||||
* Receivers declared with BOOT_COMPLETED must be fast — we hand off to
|
||||
* a foreground service within milliseconds and let the service do the
|
||||
* heavy lifting (Chaquopy bootstrap, pipeline setup).
|
||||
*/
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val action = intent.action
|
||||
Log.i(TAG, "Boot event: $action")
|
||||
|
||||
if (action != Intent.ACTION_BOOT_COMPLETED
|
||||
&& action != Intent.ACTION_LOCKED_BOOT_COMPLETED
|
||||
&& action != Intent.ACTION_MY_PACKAGE_REPLACED
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!AutostartPrefs(context).isEnabled) {
|
||||
Log.i(TAG, "Autostart disabled — skipping")
|
||||
return
|
||||
}
|
||||
|
||||
// Cheap check first (no process spawn). The real `su -c id` probe
|
||||
// would block and may not complete before the OS reclaims us.
|
||||
if (!Root.looksRooted()) {
|
||||
Log.i(TAG, "Device not rooted — cannot autostart without MediaProjection consent")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
CaptureService.createRootIntent(context),
|
||||
)
|
||||
Log.i(TAG, "LedGrab capture service dispatched on boot")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start service on boot", e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BootReceiver"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.projection.MediaProjection
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Foreground service that runs the Python LedGrab server and captures
|
||||
* the screen via MediaProjection.
|
||||
*/
|
||||
class CaptureService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CaptureService"
|
||||
private const val CHANNEL_ID = "ledgrab_capture"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val EXTRA_RESULT_CODE = "result_code"
|
||||
private const val EXTRA_RESULT_DATA = "result_data"
|
||||
private const val EXTRA_USE_ROOT = "use_root"
|
||||
private const val SERVER_PORT = 8080
|
||||
private const val CAPTURE_WIDTH = 480
|
||||
private const val CAPTURE_HEIGHT = 270
|
||||
private const val CAPTURE_FPS = 30
|
||||
|
||||
// Watchdog: if no new root-capture frames arrive inside this
|
||||
// window the pipeline is considered stalled and respawned.
|
||||
// Grace gives screenrecord time to produce its first I-frame.
|
||||
private const val WATCHDOG_GRACE_MS = 5_000L
|
||||
private const val WATCHDOG_CHECK_MS = 5_000L
|
||||
private const val WATCHDOG_MAX_RESTARTS = 3
|
||||
|
||||
/** True while the service is alive. Survives activity recreation. */
|
||||
@Volatile
|
||||
@JvmStatic
|
||||
var isRunning: Boolean = false
|
||||
private set
|
||||
|
||||
fun createIntent(
|
||||
context: Context,
|
||||
resultCode: Int,
|
||||
resultData: Intent,
|
||||
): Intent {
|
||||
return Intent(context, CaptureService::class.java).apply {
|
||||
putExtra(EXTRA_RESULT_CODE, resultCode)
|
||||
putExtra(EXTRA_RESULT_DATA, resultData)
|
||||
}
|
||||
}
|
||||
|
||||
/** Root-mode intent — no MediaProjection consent data required. */
|
||||
fun createRootIntent(context: Context): Intent {
|
||||
return Intent(context, CaptureService::class.java).apply {
|
||||
putExtra(EXTRA_USE_ROOT, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bridge: PythonBridge? = null
|
||||
private var screenCapture: ScreenCapture? = null
|
||||
private var rootCapture: RootScreenrecord? = null
|
||||
private var mediaProjection: MediaProjection? = null
|
||||
|
||||
// Service-scoped coroutine scope for the root-capture watchdog.
|
||||
// SupervisorJob so a watchdog crash doesn't tear down other children.
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private var watchdogJob: Job? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// CRITICAL: startForeground must be called IMMEDIATELY —
|
||||
// before any other work, especially before getMediaProjection().
|
||||
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
|
||||
val url = "http://$localIp:$SERVER_PORT"
|
||||
try {
|
||||
startForeground(NOTIFICATION_ID, buildNotification(url))
|
||||
} catch (e: Exception) {
|
||||
// Most common cause: missing foregroundServiceType permission
|
||||
// or denied POST_NOTIFICATIONS on API 34+.
|
||||
Log.e(TAG, "startForeground failed — service cannot run", e)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
// Only flip the public flag once the FG transition has succeeded,
|
||||
// otherwise `isRunning=true` sticks forever when startForeground throws.
|
||||
isRunning = true
|
||||
|
||||
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
|
||||
|
||||
if (intent == null && !useRoot) {
|
||||
// MediaProjection mode can't recover from a redelivery —
|
||||
// the consent token in the original intent is single-use.
|
||||
Log.w(TAG, "Service restarted without intent (MediaProjection mode) — stopping")
|
||||
isRunning = false
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
try {
|
||||
if (useRoot) {
|
||||
startRootCapture(url)
|
||||
} else {
|
||||
startMediaProjectionCapture(intent!!, url)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start capture", e)
|
||||
isRunning = false
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
// Root mode can be cleanly restarted from the original intent
|
||||
// because it carries no perishable consent token. MediaProjection
|
||||
// mode cannot — a restart there would silently start the service
|
||||
// with a dead projection. START_NOT_STICKY there, the user has
|
||||
// to tap Start again.
|
||||
return if (useRoot) START_REDELIVER_INTENT else START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun startRootCapture(url: String) {
|
||||
val newBridge = PythonBridge(this).also { b ->
|
||||
b.configureRootCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
|
||||
b.startServer(SERVER_PORT)
|
||||
}
|
||||
bridge = newBridge
|
||||
|
||||
val pipeline = RootScreenrecord(
|
||||
bridge = newBridge,
|
||||
width = CAPTURE_WIDTH,
|
||||
height = CAPTURE_HEIGHT,
|
||||
fps = CAPTURE_FPS,
|
||||
)
|
||||
if (!pipeline.start()) {
|
||||
Log.w(TAG, "Root capture failed to launch — stopping service")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
rootCapture = pipeline
|
||||
startWatchdog()
|
||||
Log.i(TAG, "LedGrab service started (root mode) — web UI at $url")
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the active root pipeline with a fresh instance, reusing
|
||||
* the existing Python bridge (no server restart). Returns true if
|
||||
* the new pipeline launched, false otherwise.
|
||||
*/
|
||||
private fun restartRootPipeline(): Boolean {
|
||||
val currentBridge = bridge ?: return false
|
||||
val old = rootCapture
|
||||
rootCapture = null
|
||||
runCatching { old?.stop() }
|
||||
|
||||
val next = RootScreenrecord(
|
||||
bridge = currentBridge,
|
||||
width = CAPTURE_WIDTH,
|
||||
height = CAPTURE_HEIGHT,
|
||||
fps = CAPTURE_FPS,
|
||||
)
|
||||
if (!next.start()) {
|
||||
Log.e(TAG, "Root capture failed to restart")
|
||||
return false
|
||||
}
|
||||
rootCapture = next
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor the root pipeline's frame counter. If no new frames
|
||||
* arrive within one check window, respawn the pipeline. Caps at
|
||||
* [WATCHDOG_MAX_RESTARTS] consecutive failures before giving up so
|
||||
* we don't burn battery forever on a device that genuinely can't
|
||||
* produce frames.
|
||||
*/
|
||||
private fun startWatchdog() {
|
||||
watchdogJob?.cancel()
|
||||
watchdogJob = serviceScope.launch {
|
||||
delay(WATCHDOG_GRACE_MS)
|
||||
var last = rootCapture?.framesDelivered ?: 0
|
||||
var restartAttempts = 0
|
||||
while (isActive) {
|
||||
delay(WATCHDOG_CHECK_MS)
|
||||
val pipe = rootCapture ?: break
|
||||
val current = pipe.framesDelivered
|
||||
if (current == last) {
|
||||
restartAttempts += 1
|
||||
Log.w(
|
||||
TAG,
|
||||
"Root capture stalled (no new frames in ${WATCHDOG_CHECK_MS}ms); " +
|
||||
"restart attempt $restartAttempts/$WATCHDOG_MAX_RESTARTS",
|
||||
)
|
||||
if (restartAttempts > WATCHDOG_MAX_RESTARTS) {
|
||||
Log.e(TAG, "Watchdog gave up after $WATCHDOG_MAX_RESTARTS restarts")
|
||||
stopSelf()
|
||||
return@launch
|
||||
}
|
||||
if (!restartRootPipeline()) {
|
||||
stopSelf()
|
||||
return@launch
|
||||
}
|
||||
last = rootCapture?.framesDelivered ?: 0
|
||||
} else {
|
||||
// Forward progress — forgive earlier glitches.
|
||||
restartAttempts = 0
|
||||
last = current
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startMediaProjectionCapture(intent: Intent, url: String) {
|
||||
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
|
||||
@Suppress("DEPRECATION")
|
||||
val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
|
||||
} else {
|
||||
intent.getParcelableExtra(EXTRA_RESULT_DATA)
|
||||
}
|
||||
|
||||
if (resultData == null) {
|
||||
Log.e(TAG, "No MediaProjection result data")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
|
||||
val projectionManager =
|
||||
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
val projection = projectionManager.getMediaProjection(resultCode, resultData)
|
||||
if (projection == null) {
|
||||
Log.e(TAG, "Failed to create MediaProjection")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
mediaProjection = projection
|
||||
|
||||
val metrics = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val windowMetrics = (getSystemService(Context.WINDOW_SERVICE) as WindowManager)
|
||||
.currentWindowMetrics
|
||||
DisplayMetrics().apply {
|
||||
val bounds = windowMetrics.bounds
|
||||
widthPixels = bounds.width()
|
||||
heightPixels = bounds.height()
|
||||
// densityDpi is still needed for VirtualDisplay; read from resources.
|
||||
densityDpi = resources.displayMetrics.densityDpi
|
||||
}
|
||||
} else {
|
||||
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
DisplayMetrics().also { m ->
|
||||
@Suppress("DEPRECATION")
|
||||
windowManager.defaultDisplay.getRealMetrics(m)
|
||||
}
|
||||
}
|
||||
|
||||
val newBridge = PythonBridge(this).also { b ->
|
||||
b.configureCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
|
||||
b.startServer(SERVER_PORT)
|
||||
}
|
||||
bridge = newBridge
|
||||
|
||||
screenCapture = ScreenCapture(
|
||||
projection = projection,
|
||||
metrics = metrics,
|
||||
bridge = newBridge,
|
||||
targetWidth = CAPTURE_WIDTH,
|
||||
targetHeight = CAPTURE_HEIGHT,
|
||||
targetFps = CAPTURE_FPS,
|
||||
// If the user taps the system Cast/Screen-capture stop banner,
|
||||
// MediaProjection.Callback.onStop fires — tear the whole
|
||||
// service down so the notification/Python server don't linger.
|
||||
onProjectionStopped = { stopSelf() },
|
||||
).also { it.start() }
|
||||
|
||||
Log.i(TAG, "LedGrab service started (MediaProjection) — web UI at $url")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
isRunning = false
|
||||
|
||||
watchdogJob?.cancel()
|
||||
watchdogJob = null
|
||||
serviceScope.cancel()
|
||||
|
||||
screenCapture?.stop()
|
||||
screenCapture = null
|
||||
|
||||
rootCapture?.stop()
|
||||
rootCapture = null
|
||||
|
||||
bridge?.stopServer()
|
||||
bridge = null
|
||||
|
||||
mediaProjection?.stop()
|
||||
mediaProjection = null
|
||||
|
||||
Log.i(TAG, "LedGrab service destroyed")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"LedGrab Screen Capture",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Shows while LedGrab is capturing the screen"
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(url: String): Notification {
|
||||
val tapIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("LedGrab Running")
|
||||
.setContentText("Web UI: $url")
|
||||
.setSmallIcon(R.drawable.ic_launcher)
|
||||
.setContentIntent(tapIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.chaquo.python.Python
|
||||
import com.chaquo.python.android.AndroidPlatform
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Application class — initializes the Chaquopy Python runtime and
|
||||
* installs a global uncaught exception handler that persists crash
|
||||
* logs to app-private storage.
|
||||
*
|
||||
* `Python.start()` must be called once before any Python code runs.
|
||||
* It loads libpython, extracts stdlib + pip packages from APK assets
|
||||
* (first launch only), and sets up `sys.path`.
|
||||
*/
|
||||
class LedGrabApp : Application() {
|
||||
|
||||
/** Set if [Python.start] threw — surfaced by MainActivity. */
|
||||
@Volatile
|
||||
var initError: Throwable? = null
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
installCrashLogger()
|
||||
try {
|
||||
if (!Python.isStarted()) {
|
||||
Python.start(AndroidPlatform(this))
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
// Don't crash here — MainActivity will render a failure
|
||||
// screen with a Copy log button so the user can report it.
|
||||
Log.e(TAG, "Python.start() failed", t)
|
||||
initError = t
|
||||
return
|
||||
}
|
||||
// Bind application context for the USB-serial bridge so Python
|
||||
// can enumerate and open USB-to-TTL adapters without needing
|
||||
// an Activity reference.
|
||||
UsbSerialBridge.init(this)
|
||||
// Bind application context for the BLE bridge so Python can
|
||||
// scan and connect to BLE LED controllers.
|
||||
BleBridge.init(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a global uncaught exception handler that writes the
|
||||
* stack trace to `files/crash-<timestamp>.log` before letting
|
||||
* the default handler terminate the process. Logs survive app
|
||||
* restarts and can be pulled via `adb pull` for diagnostics.
|
||||
*/
|
||||
private fun installCrashLogger() {
|
||||
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
try {
|
||||
val ts = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
|
||||
val logFile = File(filesDir, "crash-$ts.log")
|
||||
PrintWriter(logFile).use { pw ->
|
||||
pw.println("LedGrab crash at $ts")
|
||||
pw.println("Thread: ${thread.name}")
|
||||
pw.println()
|
||||
throwable.printStackTrace(pw)
|
||||
}
|
||||
Log.e(TAG, "Crash log written to ${logFile.absolutePath}")
|
||||
} catch (_: Exception) {
|
||||
// Best effort — don't crash inside the crash handler.
|
||||
}
|
||||
// Chain to the default handler so Android shows the crash dialog
|
||||
// and terminates the process.
|
||||
defaultHandler?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LedGrabApp"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.CheckBox
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.app.Activity
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Main (and only) Activity for the Android TV app.
|
||||
*
|
||||
* Two-state UI: stopped (Start button) and running (URL + QR + Stop).
|
||||
* Navigable with D-pad / USB controller.
|
||||
*/
|
||||
class MainActivity : Activity() {
|
||||
|
||||
// Activity-scoped coroutine scope. We don't depend on AppCompat /
|
||||
// androidx.lifecycle's lifecycleScope here because the TV launcher
|
||||
// theme inherits from Leanback (non-AppCompat).
|
||||
private val uiScope: CoroutineScope = MainScope()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
private const val SERVER_PORT = 8080
|
||||
private const val REQUEST_MEDIA_PROJECTION = 1001
|
||||
private const val REQUEST_POST_NOTIFICATIONS = 1002
|
||||
}
|
||||
|
||||
private lateinit var stoppedPanel: View
|
||||
private lateinit var runningPanel: View
|
||||
private lateinit var statusText: TextView
|
||||
private lateinit var urlText: TextView
|
||||
private lateinit var qrImage: ImageView
|
||||
private lateinit var toggleButton: Button
|
||||
private lateinit var stopButtonRunning: Button
|
||||
private lateinit var versionText: TextView
|
||||
private lateinit var autostartCheck: CheckBox
|
||||
private lateinit var autostartPrefs: AutostartPrefs
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Surface fatal Python init errors instead of crashing.
|
||||
val initError = (application as? LedGrabApp)?.initError
|
||||
if (initError != null) {
|
||||
showFatalErrorScreen(initError)
|
||||
return
|
||||
}
|
||||
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
stoppedPanel = findViewById(R.id.stopped_panel)
|
||||
runningPanel = findViewById(R.id.running_panel)
|
||||
statusText = findViewById(R.id.status_text)
|
||||
urlText = findViewById(R.id.url_text)
|
||||
qrImage = findViewById(R.id.qr_image)
|
||||
toggleButton = findViewById(R.id.toggle_button)
|
||||
stopButtonRunning = findViewById(R.id.stop_button_running)
|
||||
versionText = findViewById(R.id.version_text)
|
||||
autostartCheck = findViewById(R.id.autostart_check)
|
||||
|
||||
val versionName = packageManager
|
||||
.getPackageInfo(packageName, 0).versionName
|
||||
versionText.text = getString(R.string.version_prefix, versionName ?: "?")
|
||||
|
||||
autostartPrefs = AutostartPrefs(this)
|
||||
autostartCheck.isChecked = autostartPrefs.isEnabled
|
||||
// Autostart only takes effect on rooted devices — grey it out
|
||||
// on unrooted hardware so users don't expect magic. Cheap probe
|
||||
// (file-existence only, no process spawn).
|
||||
if (!Root.looksRooted()) {
|
||||
autostartCheck.isEnabled = false
|
||||
autostartCheck.text = getString(R.string.autostart_unavailable)
|
||||
}
|
||||
autostartCheck.setOnCheckedChangeListener { _, isChecked ->
|
||||
autostartPrefs.isEnabled = isChecked
|
||||
if (isChecked) ensureIgnoringBatteryOptimizations()
|
||||
}
|
||||
|
||||
toggleButton.setOnClickListener { startCapture() }
|
||||
stopButtonRunning.setOnClickListener { stopCaptureService() }
|
||||
|
||||
updateUI()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to go through the MediaProjection consent flow or
|
||||
* jump straight into root capture. Root check is fast but may block
|
||||
* briefly the first time Magisk shows its grant dialog — running it
|
||||
* on the UI thread is acceptable because we're responding to a
|
||||
* button press and we want to block until the user answers.
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
uiScope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startCapture() {
|
||||
// `su -c id` can block for seconds while Magisk shows its grant
|
||||
// dialog; running it on the Main thread caused ANRs.
|
||||
toggleButton.isEnabled = false
|
||||
statusText.text = "Checking root access…"
|
||||
uiScope.launch(Dispatchers.IO) {
|
||||
val rooted = Root.requestGrant()
|
||||
withContext(Dispatchers.Main) {
|
||||
toggleButton.isEnabled = true
|
||||
statusText.text = ""
|
||||
if (rooted) {
|
||||
Log.i(TAG, "Root available — skipping MediaProjection consent")
|
||||
startRootCaptureService()
|
||||
} else {
|
||||
requestMediaProjection()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestMediaProjection() {
|
||||
val manager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityForResult(manager.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION)
|
||||
}
|
||||
|
||||
private fun startRootCaptureService() {
|
||||
ensureNotificationPermission()
|
||||
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
|
||||
updateUI()
|
||||
}
|
||||
|
||||
@Deprecated("Using deprecated API for plain Activity compatibility")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == REQUEST_MEDIA_PROJECTION) {
|
||||
if (resultCode == RESULT_OK && data != null) {
|
||||
startCaptureService(resultCode, data)
|
||||
} else {
|
||||
statusText.text = "Permission denied — screen capture requires authorization"
|
||||
Log.w(TAG, "MediaProjection permission denied")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCaptureService(resultCode: Int, resultData: Intent) {
|
||||
ensureNotificationPermission()
|
||||
val intent = CaptureService.createIntent(this, resultCode, resultData)
|
||||
ContextCompat.startForegroundService(this, intent)
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private fun stopCaptureService() {
|
||||
stopService(Intent(this, CaptureService::class.java))
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private fun updateUI() {
|
||||
if (CaptureService.isRunning) {
|
||||
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
|
||||
val url = "http://$localIp:$SERVER_PORT"
|
||||
|
||||
urlText.text = url
|
||||
qrImage.setImageBitmap(null)
|
||||
// Build the bitmap pixels off the Main thread — encode + 313k
|
||||
// setPixel calls were noticeably janky on slow TV boxes.
|
||||
uiScope.launch(Dispatchers.Default) {
|
||||
val bitmap = generateQrCode(url)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (CaptureService.isRunning && urlText.text == url) {
|
||||
qrImage.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stoppedPanel.visibility = View.GONE
|
||||
versionText.visibility = View.GONE
|
||||
runningPanel.visibility = View.VISIBLE
|
||||
stopButtonRunning.requestFocus()
|
||||
} else {
|
||||
urlText.text = ""
|
||||
qrImage.setImageBitmap(null)
|
||||
|
||||
runningPanel.visibility = View.GONE
|
||||
stoppedPanel.visibility = View.VISIBLE
|
||||
versionText.visibility = View.VISIBLE
|
||||
toggleButton.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateQrCode(text: String): Bitmap {
|
||||
val size = 560
|
||||
val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size)
|
||||
val pixels = IntArray(size * size)
|
||||
for (y in 0 until size) {
|
||||
val rowOffset = y * size
|
||||
for (x in 0 until size) {
|
||||
pixels[rowOffset + x] =
|
||||
if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
|
||||
}
|
||||
}
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565)
|
||||
bitmap.setPixels(pixels, 0, size, 0, 0, size, size)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal failure UI shown when Python.start() (Chaquopy) blew up.
|
||||
* Rendered programmatically so we don't depend on the regular layout
|
||||
* (which itself may reference resources affected by the failure).
|
||||
*/
|
||||
private fun showFatalErrorScreen(error: Throwable) {
|
||||
Log.e(TAG, "Fatal init error — showing error screen", error)
|
||||
val stackText = android.util.Log.getStackTraceString(error)
|
||||
val container = android.widget.LinearLayout(this).apply {
|
||||
orientation = android.widget.LinearLayout.VERTICAL
|
||||
setPadding(48, 48, 48, 48)
|
||||
}
|
||||
val title = TextView(this).apply {
|
||||
text = "LedGrab failed to start"
|
||||
textSize = 22f
|
||||
}
|
||||
val body = TextView(this).apply {
|
||||
text = "Python runtime initialization failed:\n\n$stackText"
|
||||
textSize = 12f
|
||||
setTextIsSelectable(true)
|
||||
}
|
||||
val copyBtn = Button(this).apply {
|
||||
text = "Copy log"
|
||||
setOnClickListener {
|
||||
val cm = getSystemService(CLIPBOARD_SERVICE)
|
||||
as android.content.ClipboardManager
|
||||
cm.setPrimaryClip(
|
||||
android.content.ClipData.newPlainText("LedGrab error", stackText)
|
||||
)
|
||||
}
|
||||
}
|
||||
val scroll = android.widget.ScrollView(this).apply { addView(body) }
|
||||
container.addView(title)
|
||||
container.addView(copyBtn)
|
||||
container.addView(scroll)
|
||||
setContentView(container)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user to exempt LedGrab from battery optimization. On
|
||||
* TV boxes this is usually a no-op, but on phones Doze/App Standby
|
||||
* will kill the foreground service after a few hours of sleep. We
|
||||
* only ask when autostart is turned on. No-op on pre-M or when
|
||||
* already exempt.
|
||||
*
|
||||
* Play Store flags REQUEST_IGNORE_BATTERY_OPTIMIZATIONS by default
|
||||
* — LedGrab's ambient-capture use case falls under the documented
|
||||
* acceptable-use exceptions (a foreground service that must not be
|
||||
* killed to fulfill its primary function).
|
||||
*/
|
||||
@SuppressLint("BatteryLife")
|
||||
private fun ensureIgnoringBatteryOptimizations() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
|
||||
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
||||
if (pm.isIgnoringBatteryOptimizations(packageName)) return
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:$packageName")
|
||||
}
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
// Some TV-box OEM builds strip this intent. Fall back to the
|
||||
// generic settings screen so the user can find it manually.
|
||||
Log.w(TAG, "Direct exemption intent unavailable: ${e.message}")
|
||||
runCatching {
|
||||
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request POST_NOTIFICATIONS permission on Android 13+ so the
|
||||
* foreground service notification is visible. On older API levels
|
||||
* this is a no-op.
|
||||
*/
|
||||
private fun ensureNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
REQUEST_POST_NOTIFICATIONS,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkProperties
|
||||
import java.net.Inet4Address
|
||||
|
||||
/**
|
||||
* Network utilities for discovering the device's local IP address.
|
||||
*/
|
||||
object NetworkUtils {
|
||||
|
||||
/**
|
||||
* Return the device's local IPv4 address on the active network,
|
||||
* or `null` if unavailable.
|
||||
*/
|
||||
fun getLocalIpAddress(context: Context): String? {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = cm.activeNetwork ?: return null
|
||||
val props: LinkProperties = cm.getLinkProperties(network) ?: return null
|
||||
|
||||
return props.linkAddresses
|
||||
.map { it.address }
|
||||
.filterIsInstance<Inet4Address>()
|
||||
.firstOrNull { !it.isLoopbackAddress }
|
||||
?.hostAddress
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.chaquo.python.PyObject
|
||||
import com.chaquo.python.Python
|
||||
|
||||
/**
|
||||
* Bridge between Kotlin and the LedGrab Python server.
|
||||
*
|
||||
* All Python calls go through Chaquopy's `Python.getInstance()`.
|
||||
* Frame data crosses the JNI boundary as a `ByteArray`.
|
||||
*/
|
||||
class PythonBridge(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PythonBridge"
|
||||
}
|
||||
|
||||
private var serverThread: Thread? = null
|
||||
@Volatile private var running = false
|
||||
|
||||
// Cached PyObject handles for the per-frame fast path. Looking these
|
||||
// up via Python.getInstance().getModule(...) every frame was a real
|
||||
// measurable cost (~1ms/frame on TV boxes). Cached once at configure
|
||||
// time and read on the capture thread — @Volatile is enough for the
|
||||
// single-writer/single-reader pattern we have here.
|
||||
@Volatile private var mediaProjectionEngine: PyObject? = null
|
||||
@Volatile private var rootEngine: PyObject? = null
|
||||
|
||||
/**
|
||||
* Configure the MediaProjection engine with screen dimensions.
|
||||
* Must be called before [startServer].
|
||||
*/
|
||||
fun configureCapture(width: Int, height: Int) {
|
||||
val py = Python.getInstance()
|
||||
val engine = py.getModule("ledgrab.core.capture_engines.mediaprojection_engine")
|
||||
engine.callAttr("configure", width, height)
|
||||
mediaProjectionEngine = engine
|
||||
Log.i(TAG, "MediaProjection engine configured: ${width}x${height}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the root screenrecord engine with screen dimensions.
|
||||
* Used instead of [configureCapture] when root is available.
|
||||
*/
|
||||
fun configureRootCapture(width: Int, height: Int) {
|
||||
val py = Python.getInstance()
|
||||
val engine = py.getModule("ledgrab.core.capture_engines.root_screenrecord_engine")
|
||||
engine.callAttr("configure", width, height)
|
||||
rootEngine = engine
|
||||
Log.i(TAG, "Root screenrecord engine configured: ${width}x${height}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the LedGrab FastAPI server on a background thread.
|
||||
*
|
||||
* This blocks until [stopServer] is called, so it runs in its own thread.
|
||||
*/
|
||||
fun startServer(port: Int = 8080) {
|
||||
if (running) {
|
||||
Log.w(TAG, "Server already running")
|
||||
return
|
||||
}
|
||||
|
||||
running = true
|
||||
val dataDir = context.filesDir.absolutePath
|
||||
|
||||
serverThread = Thread({
|
||||
try {
|
||||
Log.i(TAG, "Starting Python server (dataDir=$dataDir, port=$port)")
|
||||
val py = Python.getInstance()
|
||||
val entry = py.getModule("ledgrab.android_entry")
|
||||
entry.callAttr("start_server", dataDir, port)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Python server error", e)
|
||||
} finally {
|
||||
running = false
|
||||
}
|
||||
}, "ledgrab-python-server")
|
||||
serverThread?.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal the Python server to shut down gracefully.
|
||||
*/
|
||||
fun stopServer() {
|
||||
if (!running) return
|
||||
|
||||
try {
|
||||
val py = Python.getInstance()
|
||||
val entry = py.getModule("ledgrab.android_entry")
|
||||
entry.callAttr("stop_server")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error stopping server", e)
|
||||
}
|
||||
|
||||
serverThread?.join(10_000)
|
||||
serverThread = null
|
||||
running = false
|
||||
Log.i(TAG, "Server stopped")
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a captured RGBA frame to the Python MediaProjection engine.
|
||||
*
|
||||
* Called from [ScreenCapture] on the capture thread. The byte array
|
||||
* crosses the JNI boundary — keep frames small (downscale to 480p
|
||||
* before calling).
|
||||
*/
|
||||
fun pushFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
|
||||
if (!running) return
|
||||
val engine = mediaProjectionEngine ?: return
|
||||
|
||||
try {
|
||||
engine.callAttr("push_frame", rgbaBytes, width, height)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to push frame: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a frame produced by the root `screenrecord` pipeline.
|
||||
*
|
||||
* Routed to a separate Python module so the two capture paths stay
|
||||
* independently introspectable (engine priority, availability flags,
|
||||
* dashboard diagnostics).
|
||||
*/
|
||||
fun pushRootFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
|
||||
if (!running) return
|
||||
val engine = rootEngine ?: return
|
||||
|
||||
try {
|
||||
engine.callAttr("push_frame", rgbaBytes, width, height)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to push root frame: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
val isRunning: Boolean get() = running
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.util.Log
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Lightweight root-access utilities.
|
||||
*
|
||||
* Detection is two-phase:
|
||||
* 1. Cheap check for a `su` binary in common paths (no process spawn).
|
||||
* 2. On demand, a real `su -c id` execution that returns true only when
|
||||
* the shell actually runs as UID 0. This triggers Magisk's grant
|
||||
* dialog the first time, exactly like any other root request.
|
||||
*
|
||||
* The heavier check is cached after it succeeds so we don't ask Magisk
|
||||
* repeatedly mid-session.
|
||||
*/
|
||||
object Root {
|
||||
private const val TAG = "Root"
|
||||
|
||||
private val SU_PATHS = listOf(
|
||||
"/system/bin/su",
|
||||
"/system/xbin/su",
|
||||
"/sbin/su",
|
||||
"/su/bin/su",
|
||||
"/debug_ramdisk/su",
|
||||
"/vendor/bin/su",
|
||||
)
|
||||
|
||||
@Volatile private var cachedGranted: Boolean? = null
|
||||
|
||||
/** Cheap probe: true if a known `su` binary exists. Doesn't run anything. */
|
||||
@JvmStatic
|
||||
fun looksRooted(): Boolean = SU_PATHS.any { java.io.File(it).exists() }
|
||||
|
||||
/**
|
||||
* Actually exercise `su`. Triggers Magisk's grant dialog on first call.
|
||||
* Returns true if the shell reports UID 0 within the timeout; false
|
||||
* otherwise (no root, user denied, or timeout).
|
||||
*/
|
||||
@JvmStatic
|
||||
fun requestGrant(timeoutSeconds: Long = 10): Boolean {
|
||||
cachedGranted?.let { return it }
|
||||
|
||||
if (!looksRooted()) {
|
||||
cachedGranted = false
|
||||
return false
|
||||
}
|
||||
|
||||
val granted = try {
|
||||
// redirectErrorStream merges stderr into stdout so a single
|
||||
// drain thread is enough — avoids the classic pipe-buffer
|
||||
// deadlock where waitFor() blocks because stderr filled up.
|
||||
val process = ProcessBuilder("su", "-c", "id")
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val outputBuilder = StringBuilder()
|
||||
val drain = Thread({
|
||||
try {
|
||||
BufferedReader(InputStreamReader(process.inputStream)).use { r ->
|
||||
val buf = CharArray(512)
|
||||
while (true) {
|
||||
val n = r.read(buf)
|
||||
if (n < 0) break
|
||||
synchronized(outputBuilder) { outputBuilder.append(buf, 0, n) }
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Process gone — drain ends.
|
||||
}
|
||||
}, "Root-su-drain").apply { isDaemon = true; start() }
|
||||
|
||||
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
||||
if (!finished) {
|
||||
process.destroyForcibly()
|
||||
drain.join(500)
|
||||
Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s")
|
||||
false
|
||||
} else {
|
||||
drain.join(500)
|
||||
val output = synchronized(outputBuilder) { outputBuilder.toString() }
|
||||
if (process.exitValue() != 0) {
|
||||
Log.w(TAG, "su -c id exited with ${process.exitValue()} output='${output.trim()}'")
|
||||
false
|
||||
} else {
|
||||
val rooted = output.contains("uid=0")
|
||||
Log.i(TAG, "su -c id → '${output.trim()}' → rooted=$rooted")
|
||||
rooted
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "su invocation failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
|
||||
cachedGranted = granted
|
||||
return granted
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an `su -c <cmd>` command. Returns true on exit-zero. Failure
|
||||
* invalidates the cached grant so the next [requestGrant] re-checks
|
||||
* (covers cases like Magisk grant being revoked mid-session).
|
||||
*/
|
||||
@JvmStatic
|
||||
fun runAsRoot(cmd: String, timeoutSeconds: Long = 5): Boolean {
|
||||
return try {
|
||||
val process = ProcessBuilder("su", "-c", cmd)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
||||
if (!finished) {
|
||||
process.destroyForcibly()
|
||||
cachedGranted = null
|
||||
false
|
||||
} else if (process.exitValue() != 0) {
|
||||
cachedGranted = null
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "runAsRoot('$cmd') failed: ${e.message}")
|
||||
cachedGranted = null
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget the cached grant result — useful if Magisk permission was revoked. */
|
||||
@JvmStatic
|
||||
fun invalidateCache() {
|
||||
cachedGranted = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.graphics.PixelFormat
|
||||
import android.media.ImageReader
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaFormat
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import java.io.InputStream
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Root-only screen capture backed by `/system/bin/screenrecord`.
|
||||
*
|
||||
* When the device is rooted (Magisk), we spawn ``su -c screenrecord
|
||||
* --output-format=h264 …`` and read the encoder's H.264 bitstream from
|
||||
* stdout. A MediaCodec decoder is configured to render into an
|
||||
* ImageReader's Surface, and the ImageReader's `onImageAvailable`
|
||||
* callback feeds RGBA bytes back to the Python pipeline — same sink as
|
||||
* the MediaProjection path, just a different source.
|
||||
*
|
||||
* Why bother: MediaProjection forces Android to draw a persistent
|
||||
* capture indicator overlay (unavoidable on stock Android 14+). The
|
||||
* root path sidesteps that entirely because `screenrecord` runs as
|
||||
* system UID through `su`. It also skips the one-time MediaProjection
|
||||
* consent dialog, which matters on TV-only setups where keyboard input
|
||||
* is awkward.
|
||||
*/
|
||||
class RootScreenrecord(
|
||||
private val bridge: PythonBridge,
|
||||
private val width: Int = 480,
|
||||
private val height: Int = 270,
|
||||
private val bitRate: Int = 4_000_000,
|
||||
private val fps: Int = 30,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "RootScreenrecord"
|
||||
private const val MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC
|
||||
private const val INPUT_CHUNK = 64 * 1024
|
||||
}
|
||||
|
||||
@Volatile private var process: Process? = null
|
||||
private var decoder: MediaCodec? = null
|
||||
private var imageReader: ImageReader? = null
|
||||
private var readerThread: HandlerThread? = null
|
||||
private var inputThread: Thread? = null
|
||||
private var outputThread: Thread? = null
|
||||
@Volatile private var running = false
|
||||
private val framesDeliveredCounter = AtomicInteger(0)
|
||||
@Volatile private var stopped = false
|
||||
|
||||
/** Monotonic count of frames pushed to the Python bridge. */
|
||||
val framesDelivered: Int get() = framesDeliveredCounter.get()
|
||||
|
||||
/** True once at least one frame has reached the Python bridge. */
|
||||
val hasProducedFrame: Boolean get() = framesDelivered > 0
|
||||
|
||||
/**
|
||||
* Start the capture pipeline. Returns false if `su`/`screenrecord`
|
||||
* couldn't be launched at all; true doesn't guarantee frames will
|
||||
* actually flow (the caller should monitor [hasProducedFrame] and
|
||||
* be ready to fall back to MediaProjection on timeout).
|
||||
*/
|
||||
fun start(): Boolean {
|
||||
if (running) return true
|
||||
running = true
|
||||
|
||||
try {
|
||||
imageReader = buildImageReader()
|
||||
decoder = buildDecoder(imageReader!!)
|
||||
process = spawnScreenrecord() ?: run {
|
||||
stop()
|
||||
return false
|
||||
}
|
||||
startInputPump(process!!.inputStream, decoder!!)
|
||||
startOutputDrain(decoder!!)
|
||||
Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)")
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start root capture", e)
|
||||
stop()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop everything and release resources. Idempotent. */
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
// Order matters: signal first so worker loops drop out, then
|
||||
// stop the codec on the thread that created it (this one), then
|
||||
// join workers BEFORE releasing the codec/ImageReader they may
|
||||
// still be touching, then kill the external screenrecord process.
|
||||
running = false
|
||||
|
||||
runCatching { decoder?.stop() }
|
||||
|
||||
inputThread?.interrupt()
|
||||
outputThread?.interrupt()
|
||||
runCatching { inputThread?.join(500) }
|
||||
runCatching { outputThread?.join(500) }
|
||||
inputThread = null
|
||||
outputThread = null
|
||||
|
||||
// Best-effort: kill the screenrecord child before reaping `su`,
|
||||
// otherwise screenrecord can outlive su as an orphan and keep
|
||||
// the GPU encoder busy. Fire-and-forget; ignore failures.
|
||||
runCatching { Root.runAsRoot("pkill -TERM screenrecord", timeoutSeconds = 2) }
|
||||
|
||||
runCatching { decoder?.release() }
|
||||
decoder = null
|
||||
|
||||
runCatching { imageReader?.setOnImageAvailableListener(null, null) }
|
||||
runCatching { imageReader?.close() }
|
||||
imageReader = null
|
||||
|
||||
readerThread?.quitSafely()
|
||||
runCatching { readerThread?.join(500) }
|
||||
readerThread = null
|
||||
|
||||
runCatching { process?.destroy() }
|
||||
process = null
|
||||
|
||||
Log.i(TAG, "Root capture pipeline stopped (frames delivered: ${framesDelivered})")
|
||||
}
|
||||
|
||||
private fun buildImageReader(): ImageReader {
|
||||
val thread = HandlerThread("RootReader").apply { start() }
|
||||
readerThread = thread
|
||||
val handler = Handler(thread.looper)
|
||||
|
||||
val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
|
||||
reader.setOnImageAvailableListener({ r ->
|
||||
val image = r.acquireLatestImage() ?: return@setOnImageAvailableListener
|
||||
try {
|
||||
val plane = image.planes[0]
|
||||
val buffer = plane.buffer
|
||||
val rowStride = plane.rowStride
|
||||
val pixelStride = plane.pixelStride
|
||||
val bytes = if (rowStride == width * pixelStride) {
|
||||
ByteArray(buffer.remaining()).also { buffer.get(it) }
|
||||
} else {
|
||||
// Strip row padding — common when width isn't a multiple of 16.
|
||||
val rowBytes = width * pixelStride
|
||||
ByteArray(width * height * 4).also { out ->
|
||||
for (row in 0 until height) {
|
||||
buffer.position(row * rowStride)
|
||||
buffer.get(out, row * rowBytes, rowBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
bridge.pushRootFrame(bytes, width, height)
|
||||
framesDeliveredCounter.incrementAndGet()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Root frame delivery failed: ${e.message}")
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
}, handler)
|
||||
return reader
|
||||
}
|
||||
|
||||
private fun buildDecoder(reader: ImageReader): MediaCodec {
|
||||
val format = MediaFormat.createVideoFormat(MIME_TYPE, width, height).apply {
|
||||
setInteger(MediaFormat.KEY_FRAME_RATE, fps)
|
||||
}
|
||||
val codec = MediaCodec.createDecoderByType(MIME_TYPE)
|
||||
codec.configure(format, reader.surface, null, 0)
|
||||
codec.start()
|
||||
return codec
|
||||
}
|
||||
|
||||
private fun spawnScreenrecord(): Process? {
|
||||
val cmd = buildString {
|
||||
append("screenrecord")
|
||||
append(" --output-format=h264")
|
||||
append(" --size=${width}x$height")
|
||||
append(" --bit-rate=$bitRate")
|
||||
// Time limit 0 isn't supported; the largest accepted is 180s.
|
||||
// We restart the process ourselves if it exits early.
|
||||
append(" --time-limit=180")
|
||||
append(" -")
|
||||
}
|
||||
return try {
|
||||
Runtime.getRuntime().exec(arrayOf("su", "-c", cmd))
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to spawn `su -c screenrecord`: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun startInputPump(initialStream: InputStream, codec: MediaCodec) {
|
||||
inputThread = Thread({
|
||||
val buf = ByteArray(INPUT_CHUNK)
|
||||
var stream: InputStream = initialStream
|
||||
try {
|
||||
outer@ while (running) {
|
||||
val n = try {
|
||||
stream.read(buf)
|
||||
} catch (e: Exception) {
|
||||
if (!running) break
|
||||
Log.w(TAG, "screenrecord read error: ${e.message}")
|
||||
-1
|
||||
}
|
||||
if (n <= 0) {
|
||||
if (!running) break
|
||||
// screenrecord caps at --time-limit=180s. When it
|
||||
// exits cleanly we respawn so capture survives
|
||||
// long sessions instead of freezing after ~3min.
|
||||
Log.i(TAG, "screenrecord EOF — respawning")
|
||||
runCatching { process?.destroy() }
|
||||
val next = spawnScreenrecord()
|
||||
if (next == null) {
|
||||
// Avoid a tight loop if `su` is suddenly unhappy.
|
||||
try { Thread.sleep(500) } catch (_: InterruptedException) { break }
|
||||
continue@outer
|
||||
}
|
||||
process = next
|
||||
stream = next.inputStream
|
||||
continue@outer
|
||||
}
|
||||
var offset = 0
|
||||
while (offset < n && running) {
|
||||
val index = codec.dequeueInputBuffer(50_000)
|
||||
if (index < 0) continue
|
||||
val inputBuffer = codec.getInputBuffer(index) ?: continue
|
||||
inputBuffer.clear()
|
||||
val chunk = minOf(n - offset, inputBuffer.capacity())
|
||||
inputBuffer.put(buf, offset, chunk)
|
||||
codec.queueInputBuffer(
|
||||
index,
|
||||
0,
|
||||
chunk,
|
||||
System.nanoTime() / 1_000,
|
||||
0,
|
||||
)
|
||||
offset += chunk
|
||||
}
|
||||
}
|
||||
} catch (_: InterruptedException) {
|
||||
// Expected on stop()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Input pump error: ${e.message}")
|
||||
}
|
||||
}, "RootDecoderInput").also { it.start() }
|
||||
}
|
||||
|
||||
private fun startOutputDrain(codec: MediaCodec) {
|
||||
outputThread = Thread({
|
||||
val info = MediaCodec.BufferInfo()
|
||||
try {
|
||||
while (running) {
|
||||
val index = codec.dequeueOutputBuffer(info, 50_000)
|
||||
if (index >= 0) {
|
||||
// `true` = render to the configured surface (ImageReader),
|
||||
// which triggers onImageAvailable on the reader's handler.
|
||||
codec.releaseOutputBuffer(index, true)
|
||||
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
||||
Log.i(TAG, "Decoder reported EOS")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: InterruptedException) {
|
||||
// Expected on stop()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Output drain error: ${e.message}")
|
||||
}
|
||||
}, "RootDecoderOutput").also { it.start() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PixelFormat
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.hardware.display.VirtualDisplay
|
||||
import android.media.ImageReader
|
||||
import android.media.projection.MediaProjection
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Captures the Android screen via MediaProjection and feeds frames
|
||||
* to [PythonBridge].
|
||||
*
|
||||
* Frames are downscaled to [targetWidth] x [targetHeight] before
|
||||
* crossing the JNI boundary to minimize overhead. For LED ambient
|
||||
* lighting, even 480x270 contains far more data than needed.
|
||||
*/
|
||||
class ScreenCapture(
|
||||
private val projection: MediaProjection,
|
||||
private val metrics: DisplayMetrics,
|
||||
private val bridge: PythonBridge,
|
||||
private val targetWidth: Int = 480,
|
||||
private val targetHeight: Int = 270,
|
||||
private val targetFps: Int = 30,
|
||||
private val onProjectionStopped: () -> Unit = {},
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "ScreenCapture"
|
||||
private const val VIRTUAL_DISPLAY_NAME = "LedGrabCapture"
|
||||
}
|
||||
|
||||
private var virtualDisplay: VirtualDisplay? = null
|
||||
private var imageReader: ImageReader? = null
|
||||
private var captureThread: HandlerThread? = null
|
||||
private var captureHandler: Handler? = null
|
||||
@Volatile private var running = false
|
||||
private var lastFrameTimeMs = 0L
|
||||
private val frameIntervalMs = 1000L / targetFps
|
||||
|
||||
/**
|
||||
* Start capturing the screen.
|
||||
*/
|
||||
fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
|
||||
captureThread = HandlerThread("LedGrab-Capture").also { it.start() }
|
||||
captureHandler = Handler(captureThread!!.looper)
|
||||
|
||||
// Android 14+ requires registering a callback before createVirtualDisplay
|
||||
projection.registerCallback(object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
Log.i(TAG, "MediaProjection stopped (external)")
|
||||
stop()
|
||||
// Notify the service so the foreground notification /
|
||||
// Python server get torn down too — otherwise a stale
|
||||
// "Running" notification lingers after the user taps
|
||||
// Android's system Cast/Screen-capture stop banner.
|
||||
onProjectionStopped()
|
||||
}
|
||||
}, captureHandler)
|
||||
|
||||
imageReader = ImageReader.newInstance(
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
PixelFormat.RGBA_8888,
|
||||
2, // maxImages — double buffer
|
||||
)
|
||||
|
||||
imageReader?.setOnImageAvailableListener({ reader ->
|
||||
if (!running) return@setOnImageAvailableListener
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastFrameTimeMs < frameIntervalMs) {
|
||||
// Skip frame to maintain target FPS
|
||||
reader.acquireLatestImage()?.close()
|
||||
return@setOnImageAvailableListener
|
||||
}
|
||||
|
||||
val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener
|
||||
try {
|
||||
val plane = image.planes[0]
|
||||
val buffer = plane.buffer
|
||||
val rowStride = plane.rowStride
|
||||
val pixelStride = plane.pixelStride
|
||||
|
||||
// Handle row padding: rowStride may be > width * pixelStride
|
||||
val rgbaBytes = if (rowStride == targetWidth * pixelStride) {
|
||||
// No padding — direct copy
|
||||
val bytes = ByteArray(buffer.remaining())
|
||||
buffer.get(bytes)
|
||||
bytes
|
||||
} else {
|
||||
// Strip row padding
|
||||
val rowBytes = targetWidth * pixelStride
|
||||
val bytes = ByteArray(targetWidth * targetHeight * 4)
|
||||
for (row in 0 until targetHeight) {
|
||||
buffer.position(row * rowStride)
|
||||
buffer.get(bytes, row * rowBytes, rowBytes)
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
bridge.pushFrame(rgbaBytes, targetWidth, targetHeight)
|
||||
lastFrameTimeMs = now
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Frame processing error: ${e.message}")
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
}, captureHandler)
|
||||
|
||||
virtualDisplay = projection.createVirtualDisplay(
|
||||
VIRTUAL_DISPLAY_NAME,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
metrics.densityDpi,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
imageReader?.surface,
|
||||
null,
|
||||
captureHandler,
|
||||
)
|
||||
|
||||
Log.i(TAG, "Screen capture started (${targetWidth}x${targetHeight} @ ${targetFps}fps)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop capturing and release all resources.
|
||||
*/
|
||||
fun stop() {
|
||||
running = false
|
||||
// Order matters: detach the listener BEFORE releasing the
|
||||
// VirtualDisplay so the handler can't be re-entered with stale
|
||||
// resources, then quit & join the handler thread, only then
|
||||
// close the ImageReader.
|
||||
runCatching { imageReader?.setOnImageAvailableListener(null, null) }
|
||||
runCatching { virtualDisplay?.release() }
|
||||
virtualDisplay = null
|
||||
|
||||
captureThread?.quitSafely()
|
||||
runCatching { captureThread?.join(500) }
|
||||
captureThread = null
|
||||
captureHandler = null
|
||||
|
||||
runCatching { imageReader?.close() }
|
||||
imageReader = null
|
||||
|
||||
Log.i(TAG, "Screen capture stopped")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
||||
import com.hoho.android.usbserial.driver.UsbSerialPort
|
||||
import com.hoho.android.usbserial.driver.UsbSerialProber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
|
||||
/**
|
||||
* USB-serial bridge exposed to the Python server via Chaquopy.
|
||||
*
|
||||
* Uses the `usb-serial-for-android` library (mik3y) which ships drivers
|
||||
* for the common USB-to-TTL chips (CH340, CP2102, FTDI, Prolific, and
|
||||
* CDC-ACM) found on Arduino boards and Adalight/AmbiLED controllers.
|
||||
*
|
||||
* Python callers access the singleton instance via
|
||||
* `UsbSerialBridge.INSTANCE.listDevices()` etc. — see
|
||||
* `server/src/ledgrab/core/devices/android_serial_transport.py`.
|
||||
*
|
||||
* The bridge holds no Context of its own; [init] must be called once
|
||||
* from [LedGrabApp.onCreate] to bind the application context.
|
||||
*/
|
||||
object UsbSerialBridge {
|
||||
private const val TAG = "UsbSerialBridge"
|
||||
private const val ACTION_USB_PERMISSION = "com.ledgrab.android.USB_PERMISSION"
|
||||
|
||||
@Volatile private var appContext: Context? = null
|
||||
|
||||
private val handleSeq = AtomicInteger(1)
|
||||
private val openPorts = HashMap<Int, UsbSerialPort>()
|
||||
private val initialized = AtomicBoolean(false)
|
||||
private val pendingPermissions = ConcurrentHashMap<String, CompletableDeferred<Boolean>>()
|
||||
|
||||
/** Called once from [LedGrabApp.onCreate] so we can resolve services. */
|
||||
@JvmStatic
|
||||
fun init(context: Context) {
|
||||
val app = context.applicationContext
|
||||
appContext = app
|
||||
// Idempotent: re-entrant init() must not double-register the
|
||||
// receiver (which would leak listeners and double-fire callbacks).
|
||||
if (!initialized.compareAndSet(false, true)) return
|
||||
|
||||
val filter = IntentFilter(ACTION_USB_PERMISSION)
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
val granted = intent.getBooleanExtra(
|
||||
UsbManager.EXTRA_PERMISSION_GRANTED,
|
||||
false,
|
||||
)
|
||||
val device = intent.getParcelableExtra<android.hardware.usb.UsbDevice>(
|
||||
UsbManager.EXTRA_DEVICE,
|
||||
)
|
||||
Log.i(TAG, "USB permission broadcast: granted=$granted device=${device?.deviceName}")
|
||||
device?.deviceName?.let { name ->
|
||||
pendingPermissions.remove(name)?.complete(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Android 14 requires RECEIVER_NOT_EXPORTED for non-system broadcasts.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
app.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
app.registerReceiver(receiver, filter)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ctx(): Context =
|
||||
appContext ?: error("UsbSerialBridge.init() not called — app context unavailable")
|
||||
|
||||
private fun safeSerial(driver: UsbSerialDriver): String =
|
||||
try {
|
||||
driver.device.serialNumber ?: ""
|
||||
} catch (_: SecurityException) {
|
||||
// Reading the serial requires USB permission on API 29+.
|
||||
""
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate attached USB-serial devices.
|
||||
*
|
||||
* Each entry is `"VID|PID|serial|description"` with VID/PID as
|
||||
* 4-char lowercase hex. Pipe is used as the separator so device
|
||||
* descriptions containing colons (common on FTDI strings) don't
|
||||
* confuse the Python parser.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun listDevices(): List<String> {
|
||||
val manager = ctx().getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val drivers = UsbSerialProber.getDefaultProber().findAllDrivers(manager)
|
||||
return drivers.map { driver ->
|
||||
val dev = driver.device
|
||||
val vid = "%04x".format(dev.vendorId)
|
||||
val pid = "%04x".format(dev.productId)
|
||||
val serial = safeSerial(driver)
|
||||
val description = buildString {
|
||||
append(dev.manufacturerName ?: "USB")
|
||||
val product = dev.productName
|
||||
if (!product.isNullOrBlank()) {
|
||||
append(' ')
|
||||
append(product)
|
||||
}
|
||||
}.trim().ifEmpty { "USB $vid:$pid" }
|
||||
"$vid|$pid|$serial|$description"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the first matching USB-serial device. Returns a non-negative
|
||||
* opaque handle on success, -1 on failure (device not found, user
|
||||
* denied permission, or driver error). Failures also trigger an
|
||||
* async permission-request dialog when applicable — subsequent
|
||||
* open() calls will succeed once the user grants.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun open(vendorId: Int, productId: Int, serial: String, baud: Int): Int {
|
||||
val context = ctx()
|
||||
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val drivers = UsbSerialProber.getDefaultProber().findAllDrivers(manager)
|
||||
val driver = drivers.firstOrNull { d ->
|
||||
val dev = d.device
|
||||
dev.vendorId == vendorId &&
|
||||
dev.productId == productId &&
|
||||
(serial.isEmpty() || safeSerial(d) == serial)
|
||||
}
|
||||
if (driver == null) {
|
||||
Log.w(TAG, "No matching device for $vendorId:$productId:$serial")
|
||||
return -1
|
||||
}
|
||||
|
||||
if (!manager.hasPermission(driver.device)) {
|
||||
Log.w(TAG, "USB permission not yet granted for ${driver.device.deviceName}")
|
||||
requestPermission(context, manager, driver)
|
||||
return -1
|
||||
}
|
||||
|
||||
val connection = manager.openDevice(driver.device)
|
||||
if (connection == null) {
|
||||
Log.w(TAG, "openDevice returned null for ${driver.device.deviceName}")
|
||||
return -1
|
||||
}
|
||||
val port = driver.ports.firstOrNull()
|
||||
if (port == null) {
|
||||
connection.close()
|
||||
Log.w(TAG, "Driver reports no ports for ${driver.device.deviceName}")
|
||||
return -1
|
||||
}
|
||||
|
||||
try {
|
||||
port.open(connection)
|
||||
port.setParameters(
|
||||
baud,
|
||||
8,
|
||||
UsbSerialPort.STOPBITS_1,
|
||||
UsbSerialPort.PARITY_NONE,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to configure serial port", e)
|
||||
runCatching { port.close() }
|
||||
return -1
|
||||
}
|
||||
|
||||
val handle = handleSeq.getAndIncrement()
|
||||
synchronized(openPorts) { openPorts[handle] = port }
|
||||
Log.i(
|
||||
TAG,
|
||||
"Opened USB serial ${driver.device.deviceName} baud=$baud handle=$handle",
|
||||
)
|
||||
return handle
|
||||
}
|
||||
|
||||
/** Write bytes to the previously-opened handle. Throws if invalid. */
|
||||
@JvmStatic
|
||||
fun write(handle: Int, data: ByteArray) {
|
||||
val port = synchronized(openPorts) { openPorts[handle] }
|
||||
?: throw IllegalStateException("Invalid handle $handle")
|
||||
// 1s write timeout matches the old pyserial `timeout=1` behavior.
|
||||
port.write(data, 1_000)
|
||||
}
|
||||
|
||||
/** Close a previously-opened handle. Silently ignores unknown handles. */
|
||||
@JvmStatic
|
||||
fun close(handle: Int) {
|
||||
val port = synchronized(openPorts) { openPorts.remove(handle) } ?: return
|
||||
runCatching { port.close() }
|
||||
.onFailure { Log.w(TAG, "close($handle): ${it.message}") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Block until the user grants (or denies) USB permission for the
|
||||
* device with [deviceName] (e.g. "/dev/bus/usb/001/004"). Returns
|
||||
* true if granted within [timeoutMs], false otherwise. Safe to call
|
||||
* from a Python thread via Chaquopy.
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun requestPermissionBlocking(deviceName: String, timeoutMs: Long = 15_000L): Boolean {
|
||||
val context = ctx()
|
||||
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val driver = UsbSerialProber.getDefaultProber().findAllDrivers(manager)
|
||||
.firstOrNull { it.device.deviceName == deviceName }
|
||||
?: return false
|
||||
if (manager.hasPermission(driver.device)) return true
|
||||
|
||||
// Coalesce concurrent requests for the same device — only the
|
||||
// first caller actually fires the system dialog.
|
||||
val deferred = pendingPermissions.computeIfAbsent(deviceName) {
|
||||
CompletableDeferred<Boolean>().also {
|
||||
requestPermission(context, manager, driver)
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
runBlocking {
|
||||
withTimeout(timeoutMs) { deferred.await() }
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
pendingPermissions.remove(deviceName)
|
||||
Log.w(TAG, "Permission request timed out for $deviceName")
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
pendingPermissions.remove(deviceName)
|
||||
Log.w(TAG, "Permission request failed for $deviceName: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like [open] but blocks for permission first. Use this from Python
|
||||
* instead of relying on the open()/retry pattern.
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun openWithPermission(
|
||||
vendorId: Int,
|
||||
productId: Int,
|
||||
serial: String,
|
||||
baud: Int,
|
||||
timeoutMs: Long = 15_000L,
|
||||
): Int {
|
||||
val context = ctx()
|
||||
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val driver = UsbSerialProber.getDefaultProber().findAllDrivers(manager)
|
||||
.firstOrNull { d ->
|
||||
val dev = d.device
|
||||
dev.vendorId == vendorId &&
|
||||
dev.productId == productId &&
|
||||
(serial.isEmpty() || safeSerial(d) == serial)
|
||||
} ?: return -1
|
||||
|
||||
if (!manager.hasPermission(driver.device)) {
|
||||
val granted = requestPermissionBlocking(driver.device.deviceName, timeoutMs)
|
||||
if (!granted) return -1
|
||||
}
|
||||
return open(vendorId, productId, serial, baud)
|
||||
}
|
||||
|
||||
private fun requestPermission(
|
||||
context: Context,
|
||||
manager: UsbManager,
|
||||
driver: UsbSerialDriver,
|
||||
) {
|
||||
val flags =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val intent = Intent(ACTION_USB_PERMISSION).apply {
|
||||
setPackage(context.packageName)
|
||||
}
|
||||
val pending = PendingIntent.getBroadcast(context, 0, intent, flags)
|
||||
manager.requestPermission(driver.device, pending)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true" android:color="#ffffff" />
|
||||
<item android:color="@color/purple_accent" />
|
||||
</selector>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true">
|
||||
<layer-list>
|
||||
<item android:left="-6dp" android:top="-6dp" android:right="-6dp" android:bottom="-6dp">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#4064ffda" />
|
||||
<corners android:radius="44dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#64ffda" />
|
||||
<corners android:radius="36dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
</item>
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#3dccb0" />
|
||||
<corners android:radius="36dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/teal_accent" />
|
||||
<corners android:radius="36dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true">
|
||||
<layer-list>
|
||||
<item android:left="-6dp" android:top="-6dp" android:right="-6dp" android:bottom="-6dp">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#40bb86fc" />
|
||||
<corners android:radius="44dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#7c4dff" />
|
||||
<corners android:radius="36dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
</item>
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#9966d4" />
|
||||
<corners android:radius="36dp" />
|
||||
<stroke android:width="2dp" android:color="@color/purple_accent" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#1Abb86fc" />
|
||||
<corners android:radius="36dp" />
|
||||
<stroke android:width="2dp" android:color="@color/purple_accent" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<color android:color="@color/bg_navy" />
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:gradientRadius="900dp"
|
||||
android:centerX="0.5"
|
||||
android:centerY="-0.2"
|
||||
android:startColor="#1A64ffda"
|
||||
android:endColor="#000d1117" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:gradientRadius="600dp"
|
||||
android:centerX="1.1"
|
||||
android:centerY="1.2"
|
||||
android:startColor="#12bb86fc"
|
||||
android:endColor="#000d1117" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:left="-8dp" android:top="-8dp" android:right="-8dp" android:bottom="-8dp">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#2064ffda" />
|
||||
<corners android:radius="24dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#ffffff" />
|
||||
<corners android:radius="16dp" />
|
||||
<stroke android:width="3dp" android:color="@color/teal_accent" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/green_status" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/bg_surface_elevated" />
|
||||
<corners android:radius="12dp" />
|
||||
<stroke android:width="1dp" android:color="#2264ffda" />
|
||||
</shape>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Background circle -->
|
||||
<path
|
||||
android:fillColor="#0d1117"
|
||||
android:pathData="M54,54m-50,0a50,50 0,1 1,100 0a50,50 0,1 1,-100 0" />
|
||||
<!-- Border ring -->
|
||||
<path
|
||||
android:strokeColor="#2264ffda"
|
||||
android:strokeWidth="1.5"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M54,54m-48,0a48,48 0,1 1,96 0a48,48 0,1 1,-96 0" />
|
||||
<!-- TV body -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M26,32 L82,32 Q86,32 86,36 L86,68 Q86,72 82,72 L26,72 Q22,72 22,68 L22,36 Q22,32 26,32 Z" />
|
||||
<!-- TV screen -->
|
||||
<path
|
||||
android:fillColor="#161b22"
|
||||
android:pathData="M28,35 L80,35 Q82,35 82,37 L82,66 Q82,68 80,68 L28,68 Q26,68 26,66 L26,37 Q26,35 28,35 Z" />
|
||||
<!-- LED glow - top (teal) -->
|
||||
<path
|
||||
android:fillColor="#64ffda"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
|
||||
<!-- LED glow - left (purple) -->
|
||||
<path
|
||||
android:fillColor="#bb86fc"
|
||||
android:fillAlpha="0.6"
|
||||
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
|
||||
<!-- LED glow - right (red) -->
|
||||
<path
|
||||
android:fillColor="#ff6b6b"
|
||||
android:fillAlpha="0.6"
|
||||
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
|
||||
<!-- LED glow - bottom (yellow) -->
|
||||
<path
|
||||
android:fillColor="#ffd93d"
|
||||
android:fillAlpha="0.6"
|
||||
android:pathData="M30,74 L78,74 L78,76 L30,76 Z" />
|
||||
<!-- TV stand -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M44,72 L44,78 L64,78 L64,72" />
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M38,78 L70,78 L70,80 L38,80 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Adaptive icon foreground: TV with LED glow strips.
|
||||
Centered in the 108dp safe zone (inner 72dp is guaranteed visible). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- TV body -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M26,32 L82,32 Q86,32 86,36 L86,68 Q86,72 82,72 L26,72 Q22,72 22,68 L22,36 Q22,32 26,32 Z" />
|
||||
<!-- TV screen -->
|
||||
<path
|
||||
android:fillColor="#161b22"
|
||||
android:pathData="M28,35 L80,35 Q82,35 82,37 L82,66 Q82,68 80,68 L28,68 Q26,68 26,66 L26,37 Q26,35 28,35 Z" />
|
||||
<!-- LED glow - top (teal) -->
|
||||
<path
|
||||
android:fillColor="#64ffda"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
|
||||
<!-- LED glow - left (purple) -->
|
||||
<path
|
||||
android:fillColor="#bb86fc"
|
||||
android:fillAlpha="0.6"
|
||||
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
|
||||
<!-- LED glow - right (red) -->
|
||||
<path
|
||||
android:fillColor="#ff6b6b"
|
||||
android:fillAlpha="0.6"
|
||||
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
|
||||
<!-- LED glow - bottom (yellow) -->
|
||||
<path
|
||||
android:fillColor="#ffd93d"
|
||||
android:fillAlpha="0.6"
|
||||
android:pathData="M30,74 L78,74 L78,76 L30,76 Z" />
|
||||
<!-- TV stand -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M44,72 L44,78 L64,78 L64,72" />
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M38,78 L70,78 L70,80 L38,80 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,191 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_main">
|
||||
|
||||
<!-- STOPPED STATE -->
|
||||
<LinearLayout
|
||||
android:id="@+id/stopped_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:paddingStart="160dp"
|
||||
android:paddingEnd="160dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="72dp"
|
||||
android:src="@drawable/ic_launcher"
|
||||
android:contentDescription="@null"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_name"
|
||||
android:textColor="@color/teal_accent"
|
||||
android:textSize="64sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.08"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:fontFamily="sans-serif-light" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/tagline"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="28sp"
|
||||
android:layout_marginBottom="64dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/toggle_button"
|
||||
style="@style/Widget.LedGrab.Button.Primary"
|
||||
android:layout_width="320dp"
|
||||
android:layout_height="72dp"
|
||||
android:text="@string/btn_start"
|
||||
android:textSize="22sp"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/autostart_check"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="32dp"
|
||||
android:text="@string/autostart_label"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="20sp"
|
||||
android:buttonTint="@color/teal_accent"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Version at bottom -->
|
||||
<TextView
|
||||
android:id="@+id/version_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|center_horizontal"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:textColor="@color/text_hint"
|
||||
android:textSize="18sp"
|
||||
tools:text="v0.1.0" />
|
||||
|
||||
<!-- RUNNING STATE -->
|
||||
<LinearLayout
|
||||
android:id="@+id/running_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="120dp"
|
||||
android:paddingEnd="120dp"
|
||||
android:paddingTop="80dp"
|
||||
android:paddingBottom="80dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<!-- Left: status + URL + stop -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="start|center_vertical"
|
||||
android:paddingEnd="64dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="32dp">
|
||||
|
||||
<View
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:background="@drawable/bg_status_dot"
|
||||
android:layout_marginEnd="16dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/status_running"
|
||||
android:textColor="@color/green_status"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.05" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/label_web_ui"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="22sp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/url_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/teal_accent"
|
||||
android:textSize="30sp"
|
||||
android:maxLines="1"
|
||||
android:textStyle="bold"
|
||||
android:background="@drawable/bg_url_chip"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingEnd="24dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:layout_marginBottom="56dp"
|
||||
tools:text="http://192.168.1.5:8080" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/stop_button_running"
|
||||
style="@style/Widget.LedGrab.Button.Secondary"
|
||||
android:layout_width="240dp"
|
||||
android:layout_height="64dp"
|
||||
android:text="@string/btn_stop"
|
||||
android:textSize="20sp"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Right: QR code -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_qr_container"
|
||||
android:padding="20dp"
|
||||
android:layout_marginBottom="20dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/qr_image"
|
||||
android:layout_width="280dp"
|
||||
android:layout_height="280dp"
|
||||
android:contentDescription="@string/qr_description"
|
||||
android:scaleType="fitXY" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/scan_to_configure"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="22sp"
|
||||
android:gravity="center" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/bg_navy" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">LedGrab</string>
|
||||
<string name="tagline">Фоновая подсветка для телевизора</string>
|
||||
<string name="btn_start">Начать захват</string>
|
||||
<string name="btn_stop">Стоп</string>
|
||||
<string name="status_running">Работает</string>
|
||||
<string name="label_web_ui">Адрес веб-интерфейса</string>
|
||||
<string name="scan_to_configure">Сканируйте для настройки</string>
|
||||
<string name="qr_description">QR-код для веб-интерфейса</string>
|
||||
<string name="version_prefix">v%1$s</string>
|
||||
<string name="autostart_label">Запускать при загрузке (только с root)</string>
|
||||
<string name="autostart_unavailable">Запуск при загрузке — недоступно (нужен root)</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">LedGrab</string>
|
||||
<string name="tagline">电视氛围灯光</string>
|
||||
<string name="btn_start">开始捕获</string>
|
||||
<string name="btn_stop">停止</string>
|
||||
<string name="status_running">运行中</string>
|
||||
<string name="label_web_ui">Web界面地址</string>
|
||||
<string name="scan_to_configure">扫码配置</string>
|
||||
<string name="qr_description">Web界面二维码</string>
|
||||
<string name="version_prefix">v%1$s</string>
|
||||
<string name="autostart_label">开机自启(仅限 root)</string>
|
||||
<string name="autostart_unavailable">开机自启 — 不可用(需要 root)</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="bg_navy">#0d1117</color>
|
||||
<color name="bg_navy_mid">#111827</color>
|
||||
<color name="bg_surface">#161b22</color>
|
||||
<color name="bg_surface_elevated">#1c2333</color>
|
||||
<color name="teal_accent">#64ffda</color>
|
||||
<color name="teal_accent_dim">#3399aa</color>
|
||||
<color name="teal_accent_alpha30">#4D64ffda</color>
|
||||
<color name="teal_accent_alpha15">#2664ffda</color>
|
||||
<color name="purple_accent">#bb86fc</color>
|
||||
<color name="purple_accent_dim">#7c4dff</color>
|
||||
<color name="purple_accent_alpha30">#4Dbb86fc</color>
|
||||
<color name="purple_accent_alpha15">#26bb86fc</color>
|
||||
<color name="green_status">#4caf50</color>
|
||||
<color name="green_status_dim">#1b5e20</color>
|
||||
<color name="text_primary">#e6edf3</color>
|
||||
<color name="text_secondary">#8b949e</color>
|
||||
<color name="text_hint">#484f58</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">LedGrab</string>
|
||||
<string name="tagline">Ambient lighting for your TV</string>
|
||||
<string name="btn_start">Start Capture</string>
|
||||
<string name="btn_stop">Stop</string>
|
||||
<string name="status_running">Running</string>
|
||||
<string name="label_web_ui">Web UI address</string>
|
||||
<string name="scan_to_configure">Scan to configure</string>
|
||||
<string name="qr_description">QR code for web UI</string>
|
||||
<string name="version_prefix">v%1$s</string>
|
||||
<string name="autostart_label">Start on boot (root only)</string>
|
||||
<string name="autostart_unavailable">Start on boot — unavailable (root required)</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.LedGrab" parent="@style/Theme.Leanback">
|
||||
<item name="android:windowBackground">@color/bg_navy</item>
|
||||
<item name="android:colorBackground">@color/bg_navy</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:textColorPrimary">@color/text_primary</item>
|
||||
<item name="android:textColorSecondary">@color/text_secondary</item>
|
||||
<item name="android:textColorHint">@color/text_hint</item>
|
||||
<item name="android:colorAccent">@color/teal_accent</item>
|
||||
<item name="android:colorControlHighlight">@color/teal_accent_dim</item>
|
||||
<item name="android:colorControlActivated">@color/teal_accent</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.LedGrab.Button.Primary" parent="@android:style/Widget.Button">
|
||||
<item name="android:background">@drawable/bg_button_primary</item>
|
||||
<item name="android:textColor">@color/bg_navy</item>
|
||||
<item name="android:textStyle">bold</item>
|
||||
<item name="android:focusable">true</item>
|
||||
<item name="android:stateListAnimator">@null</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.LedGrab.Button.Secondary" parent="@android:style/Widget.Button">
|
||||
<item name="android:background">@drawable/bg_button_secondary</item>
|
||||
<item name="android:textColor">@color/btn_secondary_text</item>
|
||||
<item name="android:textStyle">bold</item>
|
||||
<item name="android:focusable">true</item>
|
||||
<item name="android:stateListAnimator">@null</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
LedGrab communicates with WLED controllers, Home Assistant, and MQTT
|
||||
brokers on the local network via plain HTTP/UDP. Cleartext traffic
|
||||
must be allowed for these connections to work on Android 9+.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Cross-compile pydantic-core for Android across all three ABIs:
|
||||
# arm64-v8a (primary — real TV hardware)
|
||||
# x86_64 (modern emulators)
|
||||
# x86 (legacy emulators)
|
||||
#
|
||||
# Outputs wheels into android/wheels/. Wheels are linked against the real
|
||||
# libpython3.11.so shipped by Chaquopy (stub .so does NOT work — see
|
||||
# memory/project_android_app.md for the incident notes).
|
||||
#
|
||||
# Prerequisites (on host):
|
||||
# - Rust + cargo (rustup) with targets: aarch64/x86_64/i686-linux-android
|
||||
# - Android NDK (ANDROID_NDK_HOME, or installed at Sdk/ndk/*)
|
||||
# - Python 3.11 (matches Chaquopy's embedded version)
|
||||
# - maturin (pip install maturin)
|
||||
#
|
||||
# Rebuild cadence: whenever PYDANTIC_CORE_VERSION changes, or pydantic's
|
||||
# core dependency version changes.
|
||||
#
|
||||
# Usage:
|
||||
# ./build-pydantic-core.sh # build all three ABIs
|
||||
# ./build-pydantic-core.sh arm64 # build a single ABI
|
||||
# ./build-pydantic-core.sh arm64 x86_64 # build a subset
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ANDROID_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
WHEELS_DIR="$ANDROID_DIR/wheels"
|
||||
BUILD_DIR="$ANDROID_DIR/.build-cache"
|
||||
|
||||
PYDANTIC_CORE_VERSION="2.46.0"
|
||||
API_LEVEL=24
|
||||
PY_VERSION="3.11"
|
||||
|
||||
# Resolve a concrete python3.11 interpreter path (maturin needs it callable).
|
||||
# Windows Git Bash doesn't ship `python3.11` on PATH; fall back to `py -3.11`.
|
||||
if [ -z "${PYTHON_BIN:-}" ]; then
|
||||
# Prefer `py -3.11` on Windows (Git Bash's `python3.11` resolves to a
|
||||
# non-functional MSStore stub). Fall back to `python3.11` on Linux/macOS.
|
||||
if command -v py >/dev/null 2>&1 && py -3.11 -c '' 2>/dev/null; then
|
||||
PYTHON_BIN="$(py -3.11 -c 'import sys; print(sys.executable)' 2>/dev/null | tr -d '\r')"
|
||||
elif command -v python3.11 >/dev/null 2>&1 && python3.11 -c '' 2>/dev/null; then
|
||||
PYTHON_BIN="$(command -v python3.11)"
|
||||
fi
|
||||
fi
|
||||
[ -n "${PYTHON_BIN:-}" ] || { echo "ERROR: python3.11 not found; set PYTHON_BIN"; exit 1; }
|
||||
echo "Python: $PYTHON_BIN"
|
||||
|
||||
mkdir -p "$WHEELS_DIR"
|
||||
|
||||
# ── Find Android NDK ────────────────────────────────────────────────
|
||||
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
|
||||
for candidate in \
|
||||
"$HOME/Library/Android/sdk/ndk"/* \
|
||||
"$HOME/Android/Sdk/ndk"/* \
|
||||
"${LOCALAPPDATA:-}/Android/Sdk/ndk"/* \
|
||||
"/c/Users/$(whoami)/AppData/Local/Android/Sdk/ndk"/* \
|
||||
"/usr/local/lib/android/sdk/ndk"/* \
|
||||
"${ANDROID_SDK_ROOT:-}/ndk"/*; do
|
||||
if [ -d "$candidate" ]; then
|
||||
ANDROID_NDK_HOME="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
[ -n "${ANDROID_NDK_HOME:-}" ] || { echo "ERROR: NDK not found; set ANDROID_NDK_HOME"; exit 1; }
|
||||
echo "NDK: $ANDROID_NDK_HOME"
|
||||
|
||||
case "$(uname -s)" in
|
||||
Linux*) HOST_TAG="linux-x86_64" ;;
|
||||
Darwin*) HOST_TAG="darwin-x86_64" ;;
|
||||
MINGW*|MSYS*|CYGWIN*) HOST_TAG="windows-x86_64" ;;
|
||||
*) echo "Unsupported host OS"; exit 1 ;;
|
||||
esac
|
||||
TOOLCHAIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/$HOST_TAG"
|
||||
READELF="$TOOLCHAIN/bin/llvm-readelf"
|
||||
|
||||
# ── Prepare source tree ─────────────────────────────────────────────
|
||||
SRC_DIR="$BUILD_DIR/pydantic_core-$PYDANTIC_CORE_VERSION"
|
||||
if [ ! -d "$SRC_DIR" ]; then
|
||||
echo "Downloading pydantic_core $PYDANTIC_CORE_VERSION sdist..."
|
||||
mkdir -p "$BUILD_DIR"
|
||||
SDIST="$BUILD_DIR/pydantic_core-$PYDANTIC_CORE_VERSION.tar.gz"
|
||||
[ -f "$SDIST" ] || curl -sSL --retry 3 -o "$SDIST" \
|
||||
"https://files.pythonhosted.org/packages/source/p/pydantic_core/pydantic_core-$PYDANTIC_CORE_VERSION.tar.gz"
|
||||
tar -xzf "$SDIST" -C "$BUILD_DIR"
|
||||
fi
|
||||
|
||||
# ── ABI table ───────────────────────────────────────────────────────
|
||||
# Columns: short_name rust_target clang_prefix sysconfig_dir
|
||||
ABI_TABLE=(
|
||||
"arm64 aarch64-linux-android aarch64-linux-android${API_LEVEL} cross-sysconfig"
|
||||
"x86_64 x86_64-linux-android x86_64-linux-android${API_LEVEL} cross-sysconfig-x86_64"
|
||||
"x86 i686-linux-android i686-linux-android${API_LEVEL} cross-sysconfig-x86"
|
||||
)
|
||||
|
||||
declare -A ABI_TAG_MAP=(
|
||||
[arm64]="arm64_v8a"
|
||||
[x86_64]="x86_64"
|
||||
[x86]="x86"
|
||||
)
|
||||
|
||||
# ── Select which ABIs to build ──────────────────────────────────────
|
||||
SELECTED=("$@")
|
||||
if [ ${#SELECTED[@]} -eq 0 ]; then
|
||||
SELECTED=(arm64 x86_64 x86)
|
||||
fi
|
||||
|
||||
# ── Ensure rust targets are installed ───────────────────────────────
|
||||
for name in "${SELECTED[@]}"; do
|
||||
for row in "${ABI_TABLE[@]}"; do
|
||||
read -r sname rtarget _ _ <<<"$row"
|
||||
if [ "$sname" = "$name" ]; then
|
||||
rustup target add "$rtarget" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# ── Build loop ──────────────────────────────────────────────────────
|
||||
cd "$SRC_DIR"
|
||||
|
||||
for name in "${SELECTED[@]}"; do
|
||||
MATCHED=""
|
||||
for row in "${ABI_TABLE[@]}"; do
|
||||
read -r sname rtarget cprefix sysdir <<<"$row"
|
||||
if [ "$sname" = "$name" ]; then
|
||||
MATCHED="1"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ -n "$MATCHED" ] || { echo "Unknown ABI: $name"; exit 1; }
|
||||
|
||||
CROSS_LIB_DIR="$BUILD_DIR/$sysdir"
|
||||
[ -f "$CROSS_LIB_DIR/libpython3.11.so" ] || {
|
||||
echo "ERROR: missing $CROSS_LIB_DIR/libpython3.11.so (extract from a Chaquopy APK)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# On Windows hosts the clang wrapper is a .cmd batch file; cargo/rustc
|
||||
# can't exec it as a linker directly (os error 193). Use the .cmd path
|
||||
# explicitly so CreateProcess picks up cmd.exe as interpreter.
|
||||
CC_BIN="$TOOLCHAIN/bin/${cprefix}-clang"
|
||||
if [ "$HOST_TAG" = "windows-x86_64" ] && [ -f "${CC_BIN}.cmd" ]; then
|
||||
CC_BIN="${CC_BIN}.cmd"
|
||||
fi
|
||||
AR_BIN="$TOOLCHAIN/bin/llvm-ar"
|
||||
[ "$HOST_TAG" = "windows-x86_64" ] && [ -f "${AR_BIN}.exe" ] && AR_BIN="${AR_BIN}.exe"
|
||||
[ -f "$CC_BIN" ] || { echo "ERROR: $CC_BIN not found"; exit 1; }
|
||||
|
||||
# Normalize rust target name for env var (cargo wants UPPER_WITH_UNDERSCORES)
|
||||
TARGET_ENV=$(echo "$rtarget" | tr 'a-z-' 'A-Z_')
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " Building pydantic_core $PYDANTIC_CORE_VERSION for $name ($rtarget)"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
|
||||
env \
|
||||
CARGO_TARGET_DIR="$SRC_DIR/target" \
|
||||
"CC_${rtarget//-/_}=$CC_BIN" \
|
||||
"AR_${rtarget//-/_}=$AR_BIN" \
|
||||
"CARGO_TARGET_${TARGET_ENV}_LINKER=$CC_BIN" \
|
||||
PYO3_CROSS=1 \
|
||||
PYO3_CROSS_LIB_DIR="$CROSS_LIB_DIR" \
|
||||
PYO3_CROSS_PYTHON_VERSION="$PY_VERSION" \
|
||||
RUSTFLAGS="-C link-arg=-Wl,--no-as-needed -C link-arg=-lpython3.11 -C link-arg=-L$CROSS_LIB_DIR" \
|
||||
maturin build \
|
||||
--release \
|
||||
--target "$rtarget" \
|
||||
--interpreter "$PYTHON_BIN" \
|
||||
--out "$WHEELS_DIR" \
|
||||
--compatibility linux
|
||||
|
||||
# maturin writes the wheel with the correct android_<api>_<abi> platform
|
||||
# tag directly (the sysconfigdata provides MULTIARCH). Find + verify.
|
||||
ABI_TAG="${ABI_TAG_MAP[$name]}"
|
||||
OUT_WHL="$WHEELS_DIR/pydantic_core-$PYDANTIC_CORE_VERSION-cp311-cp311-android_${API_LEVEL}_${ABI_TAG}.whl"
|
||||
[ -f "$OUT_WHL" ] || { echo "ERROR: expected wheel not found: $OUT_WHL"; exit 1; }
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
unzip -qo "$OUT_WHL" -d "$TMP" "pydantic_core/*.so"
|
||||
SO=$(find "$TMP" -name "*.so" | head -1)
|
||||
if "$READELF" -d "$SO" | grep -q "libpython3.11.so"; then
|
||||
echo " NEEDED OK: libpython3.11.so present in $(basename "$OUT_WHL")"
|
||||
else
|
||||
echo " ERROR: libpython3.11.so NOT in NEEDED — wheel will crash at import"
|
||||
"$READELF" -d "$SO" | grep NEEDED
|
||||
rm -rf "$TMP"
|
||||
exit 1
|
||||
fi
|
||||
rm -rf "$TMP"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Build complete ==="
|
||||
ls -lh "$WHEELS_DIR"/pydantic_core-*.whl
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Set up the cross-compilation environment for building Python native
|
||||
# extensions targeting Android ARM64.
|
||||
#
|
||||
# This script:
|
||||
# 1. Verifies Android NDK is installed
|
||||
# 2. Installs the Rust aarch64-linux-android target
|
||||
# 3. Installs maturin (Python wheel builder for Rust extensions)
|
||||
# 4. Runs a quick test compile to verify the toolchain works
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== LedGrab Android NDK Setup ==="
|
||||
|
||||
# ── Check prerequisites ─────────────────────────────────────────────
|
||||
|
||||
if ! command -v rustc &>/dev/null; then
|
||||
echo "ERROR: Rust is not installed."
|
||||
echo "Install from: https://rustup.rs/"
|
||||
exit 1
|
||||
fi
|
||||
echo "Rust: $(rustc --version)"
|
||||
|
||||
if ! command -v cargo &>/dev/null; then
|
||||
echo "ERROR: Cargo is not installed."
|
||||
exit 1
|
||||
fi
|
||||
echo "Cargo: $(cargo --version)"
|
||||
|
||||
if ! command -v python3.11 &>/dev/null && ! command -v python3 &>/dev/null; then
|
||||
echo "WARNING: Python 3.11 not found. Needed for maturin builds."
|
||||
fi
|
||||
|
||||
# ── Install Rust target ─────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "Installing Rust Android target..."
|
||||
rustup target add aarch64-linux-android
|
||||
echo "Installed targets:"
|
||||
rustup target list --installed | grep android
|
||||
|
||||
# ── Install maturin ─────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "Installing maturin..."
|
||||
pip install maturin 2>/dev/null || pip3 install maturin 2>/dev/null || {
|
||||
echo "WARNING: Could not install maturin. Install manually: pip install maturin"
|
||||
}
|
||||
|
||||
if command -v maturin &>/dev/null; then
|
||||
echo "maturin: $(maturin --version)"
|
||||
fi
|
||||
|
||||
# ── Verify NDK ──────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
if [ -n "${ANDROID_NDK_HOME:-}" ]; then
|
||||
echo "ANDROID_NDK_HOME: $ANDROID_NDK_HOME"
|
||||
else
|
||||
echo "ANDROID_NDK_HOME is not set."
|
||||
echo "Set it to your NDK installation path, e.g.:"
|
||||
echo " export ANDROID_NDK_HOME=\$HOME/Android/Sdk/ndk/26.1.10909125"
|
||||
echo ""
|
||||
echo "Or install NDK via Android Studio:"
|
||||
echo " SDK Manager → SDK Tools → NDK (Side by side)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Setup complete ==="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Ensure ANDROID_NDK_HOME is set"
|
||||
echo " 2. Run: ./build-pydantic-core.sh"
|
||||
@@ -0,0 +1,5 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.9.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
id("com.chaquo.python") version "17.0.0" apply false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
org.gradle.parallel=false
|
||||
org.gradle.configuration-cache=false
|
||||
org.gradle.unsafe.isolated-projects=false
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
Vendored
+252
@@ -0,0 +1,252 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+94
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,20 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
// usb-serial-for-android (mik3y) is distributed via JitPack
|
||||
maven { url = uri("https://jitpack.io") }
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "LedGrab"
|
||||
include(":app")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -143,8 +143,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 +191,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 +200,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
|
||||
@@ -213,6 +214,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)
|
||||
@@ -321,14 +326,14 @@ 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
|
||||
|
||||
:: 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 +341,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..."
|
||||
@@ -206,14 +206,14 @@ 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
|
||||
|
||||
:: 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"
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
@@ -100,10 +100,10 @@ Section "!${APPNAME} (required)" SecCore
|
||||
Delete "$INSTDIR\LedGrab.bat"
|
||||
|
||||
; 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 +116,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 +132,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 +152,13 @@ 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
|
||||
CreateShortcut "$SMSTARTUP\${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 Descriptions ────────────────────────────────────
|
||||
+10
-9
@@ -7,7 +7,8 @@
|
||||
| File | Trigger | Purpose |
|
||||
|------|---------|---------|
|
||||
| `.gitea/workflows/test.yml` | Push/PR to master | Lint (ruff) + pytest |
|
||||
| `.gitea/workflows/release.yml` | Tag `v*` | Build artifacts + create Gitea release |
|
||||
| `.gitea/workflows/release.yml` | Tag `v*` | Build Windows/Linux/Docker artifacts + create Gitea release |
|
||||
| `.gitea/workflows/build-android.yml` | Push master (android/server paths), tag `v*`, manual | Build Android APK; attach to release on tag |
|
||||
|
||||
## Release Pipeline (`release.yml`)
|
||||
|
||||
@@ -17,7 +18,7 @@ Four parallel jobs triggered by pushing a `v*` tag:
|
||||
Creates the Gitea release with a description table listing all artifacts. **The description must stay in sync with actual build outputs** — if you add/remove/rename an artifact, update the body template here.
|
||||
|
||||
### 2. `build-windows` (cross-built from Linux)
|
||||
- Runs `build-dist-windows.sh` on Ubuntu with NSIS + msitools
|
||||
- Runs `build/build-dist-windows.sh` on Ubuntu with NSIS + msitools
|
||||
- Downloads Windows embedded Python 3.11 + pip wheels cross-platform
|
||||
- Bundles tkinter from Python MSI via msiextract
|
||||
- Builds frontend (`npm run build`)
|
||||
@@ -25,7 +26,7 @@ Creates the Gitea release with a description table listing all artifacts. **The
|
||||
- Produces: **`LedGrab-{tag}-win-x64.zip`** (portable) and **`LedGrab-{tag}-setup.exe`** (NSIS installer)
|
||||
|
||||
### 3. `build-linux`
|
||||
- Runs `build-dist.sh` on Ubuntu
|
||||
- Runs `build/build-dist.sh` on Ubuntu
|
||||
- Creates a venv, installs deps, builds frontend
|
||||
- Produces: **`LedGrab-{tag}-linux-x64.tar.gz`**
|
||||
|
||||
@@ -38,8 +39,8 @@ Creates the Gitea release with a description table listing all artifacts. **The
|
||||
|
||||
| Script | Platform | Output |
|
||||
|--------|----------|--------|
|
||||
| `build-dist-windows.sh` | Linux → Windows cross-build | ZIP + NSIS installer |
|
||||
| `build-dist.sh` | Linux native | tarball |
|
||||
| `build/build-dist-windows.sh` | Linux → Windows cross-build | ZIP + NSIS installer |
|
||||
| `build/build-dist.sh` | Linux native | tarball |
|
||||
| `server/Dockerfile` | Docker | Container image |
|
||||
|
||||
## Release Versioning
|
||||
@@ -71,7 +72,7 @@ Build scripts use a fallback chain: CLI argument → exact git tag → CI env va
|
||||
- **Launch after install**: Use `MUI_FINISHPAGE_RUN_FUNCTION` (not `MUI_FINISHPAGE_RUN_PARAMETERS` — NSIS `Exec` chokes on quoting). Still requires `MUI_FINISHPAGE_RUN ""` defined for checkbox visibility
|
||||
- **Detect running instance**: `.onInit` checks file lock on `python.exe`, offers to kill process before install
|
||||
- **Uninstall preserves user data**: Remove `python/`, `app/`, `logs/` but NOT `data/`
|
||||
- **CI build**: `sudo apt-get install -y nsis msitools zip` then `makensis -DVERSION="${VERSION}" installer.nsi`
|
||||
- **CI build**: `sudo apt-get install -y nsis msitools zip` then `makensis -DVERSION="${VERSION}" build/installer.nsi`
|
||||
|
||||
## Hidden Launcher (VBS)
|
||||
|
||||
@@ -135,12 +136,12 @@ The `create-release` job has fallback logic — if the release already exists fo
|
||||
|
||||
```bash
|
||||
npm ci && npm run build # frontend
|
||||
bash build-dist-windows.sh v1.0.0 # Windows dist
|
||||
"/c/Program Files (x86)/NSIS/makensis.exe" -DVERSION="1.0.0" installer.nsi # installer
|
||||
bash build/build-dist-windows.sh v1.0.0 # Windows dist
|
||||
"/c/Program Files (x86)/NSIS/makensis.exe" -DVERSION="1.0.0" build/installer.nsi # installer
|
||||
```
|
||||
|
||||
### Iterating on installer only
|
||||
If only `installer.nsi` changed (not app code), skip the full rebuild — just re-run `makensis`. If app code changed, re-run `build-dist-windows.sh` first since `dist/` is a snapshot.
|
||||
If only `installer.nsi` changed (not app code), skip the full rebuild — just re-run `makensis`. If app code changed, re-run `build/build-dist-windows.sh` first since `dist/` is a snapshot.
|
||||
|
||||
### Common issues
|
||||
|
||||
|
||||
+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": "Источник яркости"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# WLED Screen Controller API Documentation
|
||||
# LedGrab API Documentation
|
||||
|
||||
Complete REST API reference for the WLED Screen Controller server.
|
||||
Complete REST API reference for the LedGrab server.
|
||||
|
||||
**Base URL:** `http://localhost:8080`
|
||||
**API Version:** v1
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# BLE LED Controllers — Investigation & Implementation Notes
|
||||
|
||||
Reference for anyone touching the BLE device provider (`server/src/ledgrab/core/devices/ble_*`). Captures the protocol quirks, Windows/bleak traps, and hardware lockdown we hit while bringing up SP110E / Triones / Zengge / Govee support.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
BLEDeviceProvider → BLEClient → BLETransport (desktop: bleak, Android: Kotlin BleBridge via Chaquopy)
|
||||
│
|
||||
└─ BLEProtocol (family-specific wire bytes: sp110e.py, triones.py, zengge.py, govee.py)
|
||||
```
|
||||
|
||||
- One `BLEProtocol` dataclass per controller family. Each supplies GATT UUIDs, write type (with/without response), `encode_color` / `encode_power` functions, name prefixes for discovery, and an optional `init_writes` handshake sequence.
|
||||
- `BLEClient` is whole-strip only. `send_pixels()` averages incoming pixel arrays and emits one solid color per frame — none of these protocols support per-pixel streaming.
|
||||
- Discovery auto-detects the family via advertised name prefix first, falls back to service UUID matching. The detected family is returned on `DiscoveredDevice.ble_family` and preselected in the UI.
|
||||
- The settings modal lets users change the family after creation — wrong family → writes go to a characteristic the device ignores → strip stays dark.
|
||||
|
||||
## Protocol Quirks
|
||||
|
||||
### SP110E / SP108E (critical handshake)
|
||||
|
||||
The controller **silently tears the GATT link down within ~1 second of connect** unless a two-write handshake arrives immediately:
|
||||
|
||||
```
|
||||
Write 01 00 → characteristic FFE2
|
||||
Write 01 B7 E3 D5 → characteristic FFE1
|
||||
```
|
||||
|
||||
Without this, the first real write later hangs for 30 s because bleak thinks the link is up but the peripheral has already dropped it. We carry these writes in `PROTOCOL.init_writes` and execute them from `BLEClient.connect()` right after GATT open.
|
||||
|
||||
Color frame is **4 bytes** (`RR GG BB 1E`), not 5 — the earlier implementation had a stray `0x00` padding byte that the device tolerated but isn't documented.
|
||||
|
||||
Source: [mbullington's reverse-engineering gist](https://gist.github.com/mbullington/37957501a07ad065b67d4e8d39bfe012).
|
||||
|
||||
### Triones / Zengge / Govee
|
||||
|
||||
No init handshake required. Color frames and command bytes documented inline in each protocol module. Notable: Zengge and SP110E share service UUID `FFE0/FFE1`, so name-based identification is the only reliable way to tell them apart. In `_register_builtins()`, SP110E is registered first so it wins the `identify_family_by_service_uuids` tie by default — change this if the user base flips.
|
||||
|
||||
## bleak + Windows WinRT Traps
|
||||
|
||||
These bit us hard. All are now worked around, but future BLE work should keep them in mind.
|
||||
|
||||
### 1. `asyncio.wait_for` hangs forever on WinRT
|
||||
|
||||
`BleakClient.connect()` / `write_gatt_char()` wrap WinRT `IAsyncOperation`s. When asyncio tries to cancel them (as `wait_for` does on timeout), the WinRT task **never finishes cancelling**, so `wait_for` itself blocks forever while awaiting the cancellation. Symptom: log stops with no timeout error, process is alive but wedged.
|
||||
|
||||
**Fix**: `_bounded_await()` in [ble_transport.py](../server/src/ledgrab/core/devices/ble_transport.py) uses `asyncio.wait()` instead, which returns on timeout without awaiting pending tasks. Orphans the hanging WinRT task but frees the caller.
|
||||
|
||||
### 2. Connect by raw MAC string fails on Windows
|
||||
|
||||
Passing `BleakClient("AA:BB:CC:DD:EE:FF")` makes WinRT guess the address type (public vs random static vs random resolvable). Guesses wrong → connect silently times out. Symptom: `TimeoutError: BLE connect to ... exceeded 10.0s` with no other signal.
|
||||
|
||||
**Fix**: Always pre-scan with `BleakScanner.find_device_by_address()` and pass the returned `BLEDevice` object to `BleakClient`. Costs ~400 ms but makes connect reliable.
|
||||
|
||||
### 3. Client-side fetch timeout too short for BLE target start
|
||||
|
||||
The target-start endpoint does a ~5 s pre-scan + up to 10 s GATT connect + init handshake. Default `fetchWithAuth` has a 10 s timeout and 3× retry, so the UI was aborting and retrying concurrent `/start` requests into the server.
|
||||
|
||||
**Fix**: `startTargetProcessing` overrides `timeout: 30000, retry: false`.
|
||||
|
||||
### 4. `Start-Process -WindowStyle Hidden` from bash/WSL strips handles
|
||||
|
||||
When `restart.ps1` is invoked from Git-Bash / WSL, `Start-Process` inherited handles cause the child uvicorn to exit immediately. Stream redirection fixes it.
|
||||
|
||||
**Fix**: `restart.ps1` always uses `-RedirectStandardOutput`/`-RedirectStandardError` to a temp log. Failed startups dump the stderr tail to the caller so root cause is visible.
|
||||
|
||||
## Vendor Lockdown (the dead end)
|
||||
|
||||
Some controllers — notably the one we tested, advertising as `AlexTable` at `16:61:05:70:68:44` — **only accept connections from the vendor phone app**. Diagnostic sequence:
|
||||
|
||||
| Test | Result | Meaning |
|
||||
| --- | --- | --- |
|
||||
| LedGrab `BleakClient.connect()` | 10 s timeout | Windows can't connect |
|
||||
| Windows "Bluetooth LE Explorer" | Hangs on connect | Same Windows stack as bleak — not our bug |
|
||||
| Phone **OS** Bluetooth Settings | Can't connect | Phone OS uses generic BLE stack — also fails |
|
||||
| Phone **LED Hue** app | Connects fine | Vendor app is the *only* working client |
|
||||
|
||||
At this point, further Windows/bleak tweaks have no effect. The peripheral firmware rejects generic GATT connects and only stays connected when the LED Hue app emits its vendor-specific handshake. To unlock such a controller from LedGrab you'd need to:
|
||||
|
||||
1. Enable **Developer Options → Bluetooth HCI snoop log** on Android.
|
||||
2. Reproduce the LED Hue flow (connect → color change → disconnect).
|
||||
3. `adb bugreport bugreport.zip`; extract `btsnoop_hci.log`.
|
||||
4. Open in Wireshark; identify the vendor handshake bytes written during connect.
|
||||
5. Add them to the protocol's `init_writes`.
|
||||
|
||||
Alternatively, replace the BLE controller hardware with **WLED on ESP32** — $3, fully supported, vastly more capable.
|
||||
|
||||
## Frontend
|
||||
|
||||
- BLE family picker uses the project's shared `IconSelect` grid (project rule — see [CLAUDE.md](../CLAUDE.md): "NEVER use plain HTML `<select>`").
|
||||
- Registry in `device-discovery.ts` is keyed by element ID so both the add-device and settings modals get their own IconSelect instance. Helpers: `ensureBleFamilyIconSelect(selectId, onChange?)` / `destroyBleFamilyIconSelect(selectId)`.
|
||||
- Govee AES key row is conditionally visible: only shows when the selected family is `govee`.
|
||||
|
||||
## HAOS Integration Pair
|
||||
|
||||
The sister repo `ledgrab-haos-integration` had its own WebSocket auth bug that surfaced during this session — the integration still used the deprecated `?token=<key>` query param instead of the new first-message handshake. Fixed in [v0.2.1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab-haos-integration). Unrelated to BLE but shared debugging time.
|
||||
|
||||
## Tests
|
||||
|
||||
`server/tests/test_ble_protocols.py` and `server/tests/test_ble_client.py` use a `FakeTransport` that logs every write with its `char_uuid`, so protocol wire formats and the init handshake are unit-testable without hardware or bleak installed. New protocol additions should extend these.
|
||||
|
||||
## Files
|
||||
|
||||
- [ble_client.py](../server/src/ledgrab/core/devices/ble_client.py) — provider-facing class; runs init handshake on connect; reconnect backoff.
|
||||
- [ble_transport.py](../server/src/ledgrab/core/devices/ble_transport.py) — bleak desktop transport; `_bounded_await` helper; per-write char override.
|
||||
- [android_ble_transport.py](../server/src/ledgrab/core/devices/android_ble_transport.py) — Chaquopy/Kotlin transport; currently ignores `char_uuid` override (bridge binds a single write characteristic).
|
||||
- [ble_provider.py](../server/src/ledgrab/core/devices/ble_provider.py) — discovery, family detection, `set_color` / `set_power` short-lived sessions.
|
||||
- [ble_protocols/](../server/src/ledgrab/core/devices/ble_protocols/) — one file per family (pure byte-encoding functions, no BLE deps).
|
||||
- [BleBridge.kt](../android/app/src/main/java/com/ledgrab/android/BleBridge.kt) — Android-side BLE GATT wrapper exposed to Python via Chaquopy.
|
||||
@@ -0,0 +1,274 @@
|
||||
# Refactor Plan: Per-Provider Typed Device Configs
|
||||
|
||||
**Status:** Planned, not started.
|
||||
**Target branch:** `refactor/device-typed-configs`
|
||||
**Intended executor:** Sonnet agent (one phase per invocation; human review between phases).
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the flat [`DeviceInfo`](../../server/src/ledgrab/core/processing/target_processor.py) dataclass (and the `**kwargs`-based `LEDDeviceProvider.create_client(url, **kwargs)` contract) with a **discriminated union of per-provider config dataclasses**. Each provider owns its config type and reads typed fields instead of guessing kwargs.
|
||||
|
||||
## Motivation
|
||||
|
||||
Current pain points:
|
||||
|
||||
- [server/src/ledgrab/core/processing/wled_target_processor.py](../../server/src/ledgrab/core/processing/wled_target_processor.py) unpacks ~21 fields by hand into `create_led_client(**kwargs)`.
|
||||
- Every provider's `create_client` starts with `kwargs.get("x", default)` — no type safety, no IDE hints, no way to know at a glance which fields a provider actually uses.
|
||||
- Adding a new per-device-type field requires threading it through `Device` → `DeviceInfo` → `_DEVICE_FIELD_DEFAULTS` → call-site unpacking → kwargs bag → provider.
|
||||
- Fields leak across device types (a WLED device carries `ble_govee_key=""` at runtime for no reason).
|
||||
|
||||
## Scope guardrails
|
||||
|
||||
- **Storage schema (SQLite) unchanged.** Columns stay, dead-for-this-type fields stay, no destructive migration.
|
||||
- **Frontend HTML/TS unchanged in phases 1-4.** It already branches on `device_type` with show/hide logic. Frontend changes are deferred to Phase 5.
|
||||
- **API schemas are last.** Phase 5 converts `DeviceCreate`/`DeviceUpdate`/`DeviceResponse` to a Pydantic v2 discriminated union. This is the only breaking external change and can be deferred indefinitely if needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Config hierarchy (foundation, non-breaking)
|
||||
|
||||
### Create
|
||||
|
||||
**File:** `server/src/ledgrab/core/devices/device_config.py`
|
||||
|
||||
Pattern:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Literal, Optional, Union
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BaseDeviceConfig:
|
||||
device_id: str
|
||||
device_url: str
|
||||
led_count: int
|
||||
software_brightness: int = 255
|
||||
test_mode_active: bool = False
|
||||
auto_shutdown: bool = False
|
||||
rgbw: bool = False
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WLEDConfig(BaseDeviceConfig):
|
||||
device_type: Literal["wled"] = "wled"
|
||||
use_ddp: bool = False
|
||||
|
||||
# ... one @dataclass(frozen=True) per provider
|
||||
```
|
||||
|
||||
### Config field inventory
|
||||
|
||||
Base: `device_id`, `device_url`, `led_count`, `software_brightness`, `test_mode_active`, `auto_shutdown`, `rgbw`.
|
||||
|
||||
| Config | Extra fields beyond Base |
|
||||
| -------------- | ------------------------ |
|
||||
| WLEDConfig | `use_ddp: bool = False` |
|
||||
| AdalightConfig | `baud_rate: Optional[int] = None` |
|
||||
| AmbiLEDConfig | `baud_rate: Optional[int] = None` |
|
||||
| DMXConfig | `dmx_protocol`, `dmx_start_universe`, `dmx_start_channel` |
|
||||
| ESPNowConfig | `baud_rate`, `espnow_peer_mac`, `espnow_channel` |
|
||||
| HueConfig | `hue_username`, `hue_client_key`, `hue_entertainment_group_id` |
|
||||
| SPIConfig | `spi_speed_hz`, `spi_led_type` |
|
||||
| ChromaConfig | `chroma_device_type` |
|
||||
| GameSenseConfig| `gamesense_device_type` |
|
||||
| BLEConfig | `ble_family`, `ble_govee_key` |
|
||||
| GroupConfig | `group_mode`, `group_device_ids` (**no `device_store` here** — see Phase 2) |
|
||||
| OpenRGBConfig | `zone_mode` |
|
||||
| MockConfig | `send_latency_ms: int = 0` |
|
||||
| DemoConfig | `send_latency_ms: int = 0` |
|
||||
| MQTTConfig | (none) |
|
||||
| WSConfig | (none) |
|
||||
| USBHIDConfig | (none — `hid_usage_page` is parsed from the URL, not config) |
|
||||
|
||||
```python
|
||||
DeviceConfig = Union[
|
||||
WLEDConfig, AdalightConfig, AmbiLEDConfig, DMXConfig, ESPNowConfig,
|
||||
HueConfig, SPIConfig, ChromaConfig, GameSenseConfig, BLEConfig,
|
||||
GroupConfig, MQTTConfig, WSConfig, USBHIDConfig, OpenRGBConfig,
|
||||
MockConfig, DemoConfig,
|
||||
]
|
||||
```
|
||||
|
||||
### Add
|
||||
|
||||
**`Device.to_config() -> DeviceConfig`** in [server/src/ledgrab/storage/device_store.py](../../server/src/ledgrab/storage/device_store.py) (around lines 14-97 where `Device` lives).
|
||||
|
||||
- Dispatches on `self.device_type`.
|
||||
- Constructs the right subclass, pulling only relevant columns.
|
||||
- Ignores columns that don't apply to the type.
|
||||
- This is the **only** place that knows the flat→typed mapping.
|
||||
|
||||
### Do NOT touch in Phase 1
|
||||
|
||||
- Provider signatures (still `create_client(self, url, **kwargs)`).
|
||||
- `create_led_client` factory.
|
||||
- Any call site.
|
||||
- `DeviceInfo` itself.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- New unit test `server/tests/core/devices/test_device_config.py`:
|
||||
- For each provider, build a `Device` with that `device_type`, call `to_config()`, assert right subclass and right fields.
|
||||
- Edge case: extra/irrelevant Device fields must not leak into the wrong config type.
|
||||
- `cd server && ruff check src/ tests/ --fix` — green.
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — green (existing tests untouched, new test passes).
|
||||
- `cd server && npx tsc --noEmit` — green (no TS impact this phase, just a sanity check).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 + Phase 3 — Provider API migration + call-site migration (single PR)
|
||||
|
||||
**These must land in one commit** because the provider signature change would otherwise break the 3 call sites immediately.
|
||||
|
||||
### Change the abstract base
|
||||
|
||||
[server/src/ledgrab/core/devices/led_client.py](../../server/src/ledgrab/core/devices/led_client.py):
|
||||
|
||||
```python
|
||||
class LEDDeviceProvider(ABC):
|
||||
@abstractmethod
|
||||
def create_client(self, config: DeviceConfig, *, deps: ProviderDeps) -> LEDClient: ...
|
||||
```
|
||||
|
||||
`ProviderDeps` is a tiny new dataclass:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class ProviderDeps:
|
||||
device_store: "DeviceStore"
|
||||
# Add future cross-cutting runtime deps here (http_client, etc.)
|
||||
```
|
||||
|
||||
`create_led_client`:
|
||||
|
||||
```python
|
||||
def create_led_client(config: DeviceConfig, *, deps: ProviderDeps) -> LEDClient:
|
||||
return get_provider(config.device_type).create_client(config, deps=deps)
|
||||
```
|
||||
|
||||
### Update every provider (17 files)
|
||||
|
||||
- Narrow signature per provider: e.g. `WLEDDeviceProvider.create_client(self, config: WLEDConfig, *, deps: ProviderDeps)`.
|
||||
- Drop all `kwargs.get("x")` lookups — read typed fields directly.
|
||||
- Providers that don't need `deps` just ignore it.
|
||||
- **GroupDeviceProvider** is the only current consumer of `deps`: reads `deps.device_store`.
|
||||
|
||||
### Call sites (3)
|
||||
|
||||
1. [server/src/ledgrab/core/processing/wled_target_processor.py](../../server/src/ledgrab/core/processing/wled_target_processor.py) lines ~120-148 — the 21-field unpacking. Replace with:
|
||||
```python
|
||||
config = device.to_config()
|
||||
self._led_client = create_led_client(config, deps=self._provider_deps)
|
||||
```
|
||||
`self._provider_deps` is plumbed in from `ProcessorManager` when the target processor is constructed.
|
||||
2. [server/src/ledgrab/core/processing/device_test_mode.py](../../server/src/ledgrab/core/processing/device_test_mode.py) lines 72-78 — minimal test-mode client. Build a synthetic config via a helper `_minimal_config_for_test_mode(device)` (keeps just `device_id`, `device_url`, `led_count`, `baud_rate`) and pass it.
|
||||
3. [server/src/ledgrab/core/devices/group_client.py](../../server/src/ledgrab/core/devices/group_client.py) lines 47-70 — child client construction inside the group. Same pattern: `child_config = child_device.to_config()`; pass `deps` through.
|
||||
|
||||
### Delete
|
||||
|
||||
- `DeviceInfo` dataclass in [server/src/ledgrab/core/processing/target_processor.py](../../server/src/ledgrab/core/processing/target_processor.py) lines 71-109.
|
||||
- `ProcessorManager._get_device_info()` and `_DEVICE_FIELD_DEFAULTS` in [server/src/ledgrab/core/processing/processor_manager.py](../../server/src/ledgrab/core/processing/processor_manager.py) lines 230-275 — `Device.to_config()` subsumes this. Verify no other callers via `ast-index usages "_get_device_info"`.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- `ast-index search "device_info\."` — no hits in non-test code.
|
||||
- `ast-index search "DeviceInfo"` — no hits outside archival comments.
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — all tests pass.
|
||||
- Manual smoke: start server, create a WLED device, start processing, verify LEDs update (or mock output shows frames).
|
||||
- `cd server && ruff check src/ tests/ --fix` — green.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Test migration
|
||||
|
||||
Update these files:
|
||||
|
||||
- `server/tests/storage/test_device_store.py` — add `to_config()` cases per device type.
|
||||
- `server/tests/api/routes/test_devices_routes.py` — should be mostly untouched (API schemas still flat until Phase 5).
|
||||
- `server/tests/e2e/test_device_flow.py` — update internal assertions only if they touch `DeviceInfo` directly.
|
||||
- `server/tests/test_group_device.py` — construct child clients with `GroupConfig`.
|
||||
- Any fixture helper that builds a fake `DeviceInfo` — migrate to the right `*Config` subclass.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — all green.
|
||||
- Coverage of `device_config.py` and `Device.to_config()` ≥ 90%.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — API discriminated union (OPTIONAL, separate PR)
|
||||
|
||||
**Do not start until Phases 1-4 are merged and stable.** Flag this to the human before beginning. This is the only phase with an externally breaking change.
|
||||
|
||||
### Backend
|
||||
|
||||
[server/src/ledgrab/api/schemas/devices.py](../../server/src/ledgrab/api/schemas/devices.py) — replace flat `DeviceCreate`/`DeviceUpdate` with Pydantic v2 tagged unions:
|
||||
|
||||
```python
|
||||
class WLEDDeviceCreate(BaseModel):
|
||||
device_type: Literal["wled"]
|
||||
name: str
|
||||
url: str
|
||||
led_count: int
|
||||
use_ddp: bool = False
|
||||
# ... base fields only
|
||||
|
||||
DeviceCreate = Annotated[
|
||||
Union[WLEDDeviceCreate, AdalightDeviceCreate, ...],
|
||||
Field(discriminator="device_type"),
|
||||
]
|
||||
```
|
||||
|
||||
Add `model_config = ConfigDict(extra="ignore")` on each union member for **one release cycle** so existing clients (frontend, HAOS integration, curl scripts) that send extra fields don't 422 immediately. Add a deprecation note and tighten to `extra="forbid"` in a follow-up.
|
||||
|
||||
### Frontend
|
||||
|
||||
- [server/src/ledgrab/static/js/features/devices.ts](../../server/src/ledgrab/static/js/features/devices.ts) and related — when building the POST/PATCH body, scope the payload to the selected `device_type` using the show/hide knowledge already in `device-discovery.ts`.
|
||||
- **No plain `<select>` elements** — any new pickers use IconSelect or EntitySelect (see root CLAUDE.md UI rules).
|
||||
|
||||
### Tests
|
||||
|
||||
- Update `test_devices_routes.py` to assert discriminated union rejection of mismatched shapes.
|
||||
- Add round-trip tests: create device of each type via API → fetch → compare fields.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — green.
|
||||
- `cd server && npx tsc --noEmit && npm run build` — green.
|
||||
- Manual smoke for at least 3 device types (WLED, DMX, Hue) — create, edit, delete via UI.
|
||||
- HAOS integration still works against the server (spot-check; not automated).
|
||||
|
||||
---
|
||||
|
||||
## Conventions the implementing agent must follow
|
||||
|
||||
- **Project task tracker is `TODO.md`** — check the "Refactor: Per-Provider Device Configs" section, tick boxes as phases land. Do **not** use the `TodoWrite` tool.
|
||||
- **Auto-restart after Python changes.** See [contexts/server-operations.md](../../contexts/server-operations.md).
|
||||
- **No commits without explicit user approval.** Present each phase's diff for review first.
|
||||
- **Pre-commit gate every phase:**
|
||||
- `cd server && ruff check src/ tests/ --fix`
|
||||
- `cd server && py -3.13 -m pytest tests/ --no-cov -q`
|
||||
- Phase 5 additionally: `cd server && npx tsc --noEmit && npm run build`
|
||||
- **No plain `<select>`** — Phase 5 uses IconSelect / EntitySelect.
|
||||
- **Android parity:** if you add any new runtime dep to `server/pyproject.toml`, update `android/app/build.gradle.kts` per the root [CLAUDE.md](../../CLAUDE.md) "Android Dependency Sync" section. This refactor should not need any new deps.
|
||||
- **Data migration policy:** storage schema is unchanged, so no JSON-file migration is needed. But if you rename any serialized field during `to_dict`/`from_dict`, add migration logic per the root [CLAUDE.md](../../CLAUDE.md) "Data Migration Policy" section.
|
||||
- **Use `ast-index`** for code search (`ast-index search`, `ast-index usages`, `ast-index callers`, `ast-index class`). Fall back to Grep only for regex/string-literal/comment searches.
|
||||
- **Never run `cd` in Bash.** Use absolute paths or the project-relative `cd server && <cmd>` idiom (one-shot, same invocation).
|
||||
|
||||
## Known risks
|
||||
|
||||
1. **Frozen dataclass + inheritance + defaults** — Python's `@dataclass(frozen=True)` with inheritance requires every subclass field to have a default if any parent field does. Base has defaulted fields. Verify in Phase 1. If it breaks, use `kw_only=True` (Python 3.10+).
|
||||
2. **`use_ddp` origin** — currently inferred from `self._protocol == "ddp"` at the call site, not from Device storage. Options: add a column (schema change, more work), **or** keep inference logic inside `Device.to_config()` (recommended — no schema change). Prefer the latter.
|
||||
3. **Test-mode minimal client** ([device_test_mode.py](../../server/src/ledgrab/core/processing/device_test_mode.py) lines 72-78) may not have all `BaseDeviceConfig` fields available. Build a synthetic config via a named helper; do not leak the hack into `Device.to_config()`.
|
||||
4. **Group `device_store` import cycle** — `GroupConfig` must **not** hold `device_store` (would pull storage into the config module). `ProviderDeps` is the deliberate cut.
|
||||
5. **BLE optional import** — `BLEDeviceProvider` is conditionally registered (see [led_client.py](../../server/src/ledgrab/core/devices/led_client.py) lines 321-330). Ensure `BLEConfig` still imports cleanly even when `bleak` is absent — put `BLEConfig` in `device_config.py` (not in `ble_provider.py`) so it's always importable.
|
||||
|
||||
## Deliverables per phase
|
||||
|
||||
1. Branch: `refactor/device-typed-configs`.
|
||||
2. One commit per phase, conventional-commit messages:
|
||||
- `refactor(devices): phase 1 — add DeviceConfig hierarchy`
|
||||
- `refactor(devices): phases 2+3 — typed provider signatures + call-site migration`
|
||||
- `refactor(devices): phase 4 — test migration to typed configs`
|
||||
- `refactor(devices): phase 5 — API discriminated union` (separate PR)
|
||||
3. Phase-by-phase diffs presented for user review **before** each commit.
|
||||
4. Final PR body linking all phases, with manual test plan per device type touched.
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "WLED Screen Controller",
|
||||
"render_readme": true,
|
||||
"country": ["US"],
|
||||
"homeassistant": "2023.1.0"
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
# math_wave Color Strip Source — Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
A new CSS type that generates LED colors from configurable mathematical wave functions. Each LED position gets a wave value based on spatial position and time, mapped to a color via a gradient palette. Supports multiple superimposed wave layers, sync clocks, and bindable parameters.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Waveform types: sine, triangle, sawtooth, square
|
||||
- Parameters per wave layer: waveform, frequency, amplitude, phase, offset
|
||||
- Global parameters: speed (bindable), gradient_id (color mapping)
|
||||
- Wave superposition: list of wave layers combined additively
|
||||
- Spatial dimension: wave value depends on LED position (0.0-1.0) + time
|
||||
- Sync clock integration for time parameter
|
||||
- Color mapping: combined wave output (0.0-1.0) mapped through a gradient
|
||||
|
||||
## Phase 1: Storage Model
|
||||
|
||||
**File: `server/src/wled_controller/storage/color_strip_source.py`**
|
||||
- Add `MathWaveColorStripSource` dataclass after `GameEventColorStripSource`
|
||||
- Fields:
|
||||
- `waves: list` — default `[{"waveform": "sine", "frequency": 1.0, "amplitude": 1.0, "phase": 0.0, "offset": 0.0}]`
|
||||
- `speed: BindableFloat` — default 1.0
|
||||
- `gradient_id: Optional[str]` — references Gradient entity
|
||||
- Implement `to_dict`, `from_dict`, `create_from_kwargs`, `apply_update` (follow `CandlelightColorStripSource` pattern)
|
||||
- Valid waveforms: `{"sine", "triangle", "sawtooth", "square"}`
|
||||
- Add `"math_wave": MathWaveColorStripSource` to `_SOURCE_TYPE_MAP`
|
||||
|
||||
## Phase 2: Stream Implementation
|
||||
|
||||
**File: `server/src/wled_controller/core/processing/math_wave_stream.py`** (new)
|
||||
- Class `MathWaveColorStripStream(ColorStripStream)` following `CandlelightColorStripStream` pattern
|
||||
- Key methods: `__init__`, `_update_from_source`, `configure`, `start`, `stop`, `get_latest_colors`, `update_source`, `set_clock`, `set_gradient_store`
|
||||
- Animation loop (`_animate_loop`):
|
||||
- Get `t` from clock (if set) or wall clock
|
||||
- For each LED at normalized position `p = i / (N-1)`:
|
||||
- Sum all wave layers: `sum += amplitude * waveform(2*pi*frequency*(p + speed*t) + phase) + offset`
|
||||
- Clamp result to [0.0, 1.0]
|
||||
- Map per-LED values to RGB via gradient LUT
|
||||
- Waveform functions (vectorized with numpy):
|
||||
- `sine`: `0.5 + 0.5 * np.sin(x)`
|
||||
- `triangle`: `2.0 * np.abs(np.mod(x / (2*pi), 1.0) - 0.5)`
|
||||
- `sawtooth`: `np.mod(x / (2*pi), 1.0)`
|
||||
- `square`: `(np.sin(x) >= 0).astype(float)`
|
||||
- Double-buffering pattern (same as candlelight)
|
||||
- Use `self.resolve("speed", self._speed)` for bindable speed
|
||||
- Gradient resolution via `set_gradient_store` pattern
|
||||
|
||||
## Phase 3: API Integration
|
||||
|
||||
**File: `server/src/wled_controller/api/schemas/color_strip_sources.py`**
|
||||
- Add `MathWaveCSSResponse`, `MathWaveCSSCreate`, `MathWaveCSSUpdate`
|
||||
- Add to `ColorStripSourceResponse`, `ColorStripSourceCreate`, `ColorStripSourceUpdate` unions
|
||||
|
||||
**File: `server/src/wled_controller/core/processing/color_strip_stream_manager.py`**
|
||||
- Import `MathWaveColorStripStream`
|
||||
- Add `"math_wave": MathWaveColorStripStream` to `_SIMPLE_STREAM_MAP`
|
||||
- Existing `set_gradient_store` and `_inject_clock` injection handles it automatically
|
||||
|
||||
## Phase 4: Frontend
|
||||
|
||||
**File: `server/src/wled_controller/static/js/types.ts`**
|
||||
- Add `'math_wave'` to `CSSSourceType` union
|
||||
|
||||
**File: `server/src/wled_controller/static/js/core/icons.ts`**
|
||||
- Add `math_wave: _svg(P.activity)` to `_colorStripTypeIcons`
|
||||
|
||||
**File: `server/src/wled_controller/templates/modals/css-editor.html`**
|
||||
- Add `<option value="math_wave">` to type select
|
||||
- Add `<div id="css-editor-math-wave-section">` with:
|
||||
- Gradient picker (EntitySelect)
|
||||
- Speed (BindableScalarWidget)
|
||||
- Wave layers list (dynamic rows: waveform IconSelect, frequency/amplitude/phase/offset inputs, add/remove buttons)
|
||||
|
||||
**File: `server/src/wled_controller/static/js/features/color-strips.ts`**
|
||||
- Add to `CSS_TYPE_KEYS`, `CSS_SECTION_MAP`, `CSS_TYPE_SETUP`, `NON_PICTURE_TYPES`
|
||||
- Add to `clockTypes` array in `saveCSSEditor`
|
||||
- Add type handler: `load(css)`, `reset()`, `getPayload(name)`
|
||||
- Add card renderer showing wave count, gradient swatch, speed, clock badge
|
||||
- Add wave layer management: `_renderMathWaveRow`, `addMathWaveLayer`, `removeMathWaveLayer`
|
||||
|
||||
**Files: `en.json`, `ru.json`, `zh.json`**
|
||||
- Add i18n keys for type name, description, all field labels, waveform names
|
||||
|
||||
## Phase 5: Testing
|
||||
|
||||
**File: `server/tests/core/test_math_wave_stream.py`** (new)
|
||||
- Test wave functions produce expected values at known inputs
|
||||
- Test single wave spatial pattern
|
||||
- Test wave superposition
|
||||
- Test gradient color mapping
|
||||
- Test clock integration
|
||||
- Test `update_source` hot-update
|
||||
- Test `configure` auto-sizing
|
||||
|
||||
**File: `server/tests/e2e/test_color_strip_flow.py`**
|
||||
- Add `test_math_wave_crud` to lifecycle tests
|
||||
|
||||
**Storage model tests:**
|
||||
- Test `from_dict` roundtrip
|
||||
- Test `create_from_kwargs` with valid/invalid waveforms
|
||||
- Test `apply_update`
|
||||
- Test `_SOURCE_TYPE_MAP` dispatch
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
- **Wave layer UI complexity** — Follow existing composite layers / game event mappings patterns
|
||||
- **Performance with many layers** — Vectorize with numpy; cap max wave layers to 8
|
||||
- **Gradient resolution** — Stream manager already injects `set_gradient_store` automatically
|
||||
@@ -1,151 +0,0 @@
|
||||
# music_sync Color Strip Source — Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
A higher-level music-reactive CSS that provides semantic audio analysis (BPM detection, beat tracking, energy envelope, drop detection, frequency band energy) and multiple visualization modes. Builds on existing `AudioCaptureManager` and `AudioAnalysis` infrastructure. No new external dependencies — uses numpy-only analysis.
|
||||
|
||||
## Requirements
|
||||
|
||||
- BPM estimation from real-time audio
|
||||
- Beat onset detection with configurable threshold
|
||||
- Smoothed RMS energy envelope with attack/release
|
||||
- Drop detection: energy drops/buildups
|
||||
- Frequency band energy: bass (20-250 Hz), mid (250-4k Hz), treble (4k-20k Hz)
|
||||
- Four visualization modes: `pulse_on_beat`, `energy_gradient`, `spectrum_bands`, `strobe_on_drop`
|
||||
- Uses existing audio engine infrastructure (no new audio capture code)
|
||||
- No external dependencies beyond numpy
|
||||
|
||||
## Phase 1: Music Analysis Engine
|
||||
|
||||
**File: `server/src/wled_controller/core/audio/music_analyzer.py`** (new)
|
||||
|
||||
### 1.1 `MusicFeatures` dataclass (frozen=True)
|
||||
- `bpm: float` — estimated BPM (0 if unknown)
|
||||
- `beat: bool` — beat detected this frame
|
||||
- `beat_intensity: float` — 0.0-1.0
|
||||
- `beat_phase: float` — 0.0-1.0 position in beat cycle
|
||||
- `energy: float` — smoothed RMS 0.0-1.0
|
||||
- `energy_delta: float` — rate of change
|
||||
- `bass_energy, mid_energy, treble_energy: float` — 0.0-1.0
|
||||
- `drop_state: str` — "idle"|"buildup"|"drop"|"recovery"
|
||||
- `drop_intensity: float` — 0.0-1.0
|
||||
|
||||
### 1.2 `MusicAnalyzer` class
|
||||
- **State**: rolling energy buffer (~4s at ~43 Hz = ~172 samples), beat history timestamps (last 30), smoothed band energies, BPM estimate, drop state machine
|
||||
- **`update(analysis: AudioAnalysis) -> MusicFeatures`**: main entry point
|
||||
- **BPM estimation**: Track beat timestamps, compute median inter-beat interval, exponential smoothing, clamp 40-220 BPM
|
||||
- **Beat tracking**: Pass through `AudioAnalysis.beat` + compute `beat_phase` (position in current beat cycle)
|
||||
- **Energy envelope**: Smoothed RMS with configurable attack/release
|
||||
- **Drop detection**: State machine: `idle -> buildup` (energy rising steadily 1-2s), `buildup -> drop` (energy drops >50% within 100ms), `drop -> recovery` (after 500ms), `recovery -> idle`
|
||||
- **Frequency bands**: Sum spectrum bins into 3 bands from 64-band spectrum
|
||||
|
||||
## Phase 2: Storage Model
|
||||
|
||||
**File: `server/src/wled_controller/storage/color_strip_source.py`**
|
||||
- Add `MusicSyncColorStripSource` dataclass after `AudioColorStripSource`
|
||||
- Fields:
|
||||
- `visualization_mode: str` — default `"pulse_on_beat"`
|
||||
- `audio_source_id: str` — references AudioSource
|
||||
- `sensitivity: BindableFloat` — default 1.0
|
||||
- `smoothing: BindableFloat` — default 0.3
|
||||
- `palette: str` — default `"rainbow"`
|
||||
- `gradient_id: Optional[str]`
|
||||
- `color: BindableColor` — primary color
|
||||
- `color_secondary: BindableColor` — for two-color modes
|
||||
- `beat_decay: BindableFloat` — default 0.15
|
||||
- `led_count: int` — 0 = auto-size
|
||||
- `mirror: bool`
|
||||
- Add `"music_sync": MusicSyncColorStripSource` to `_SOURCE_TYPE_MAP`
|
||||
|
||||
## Phase 3: Stream Implementation
|
||||
|
||||
**File: `server/src/wled_controller/core/processing/music_sync_stream.py`** (new)
|
||||
- Class `MusicSyncColorStripStream(ColorStripStream)` following `AudioColorStripStream` pattern
|
||||
- Constructor: accept source + audio_capture_manager + stores. Create `MusicAnalyzer` instance
|
||||
- `start()`: Acquire audio stream, start background thread
|
||||
- `stop()`: Release audio stream, stop thread
|
||||
- `_animate_loop()`:
|
||||
1. Get `AudioAnalysis` from audio stream
|
||||
2. Apply audio filter pipeline (if any)
|
||||
3. Feed to `MusicAnalyzer.update()` → `MusicFeatures`
|
||||
4. Dispatch to visualization renderer
|
||||
5. Double-buffer output
|
||||
|
||||
### Visualization Renderers
|
||||
|
||||
- **`pulse_on_beat`**: Full-strip flash on beat with exponential decay. Between beats: sine-wave pulsing synced to BPM. Color from palette indexed by beat_intensity.
|
||||
- **`energy_gradient`**: Maps bass→warm, treble→cool. Overall brightness from energy. Gradient scrolls with beat_phase.
|
||||
- **`spectrum_bands`**: 3 zones (bass/mid/treble), each fills proportionally to band energy. Mirror mode: bass center, treble edges.
|
||||
- **`strobe_on_drop`**: Idle=gentle breathing. Buildup=increasing pulse. Drop=rapid strobe at 10 Hz. Recovery=fade back.
|
||||
|
||||
## Phase 4: API Integration
|
||||
|
||||
**File: `server/src/wled_controller/api/schemas/color_strip_sources.py`**
|
||||
- Add `MusicSyncCSSResponse`, `MusicSyncCSSCreate`, `MusicSyncCSSUpdate`
|
||||
- Add to all three union types
|
||||
|
||||
**File: `server/src/wled_controller/api/routes/color_strip_sources.py`**
|
||||
- Import + add to `_RESPONSE_MAP`
|
||||
|
||||
**File: `server/src/wled_controller/core/processing/color_strip_stream_manager.py`**
|
||||
- Add `music_sync` branch in `acquire()` (same pattern as `audio` branch)
|
||||
- Update `refresh_audio_filter_pipelines()` to include `MusicSyncColorStripStream`
|
||||
|
||||
## Phase 5: Frontend
|
||||
|
||||
**File: `server/src/wled_controller/static/js/core/icons.ts`**
|
||||
- Add `music_sync: _svg(P.radio)` icon
|
||||
|
||||
**File: `server/src/wled_controller/templates/modals/css-editor.html`**
|
||||
- Add `<div id="css-editor-music-sync-section">` with:
|
||||
- Visualization mode selector (IconSelect)
|
||||
- Audio source dropdown
|
||||
- Sensitivity, Smoothing, Beat Decay (BindableScalarWidget containers)
|
||||
- Palette/gradient selector (EntitySelect)
|
||||
- Primary + Secondary color (BindableColorWidget)
|
||||
- Mirror checkbox
|
||||
|
||||
**File: `server/src/wled_controller/static/js/features/color-strips-music-sync.ts`** (new, extracted)
|
||||
- Editor logic, widget factories, card renderer
|
||||
|
||||
**File: `server/src/wled_controller/static/js/features/color-strips.ts`**
|
||||
- Register type in `CSS_TYPE_KEYS`, `CSS_SECTION_MAP`, `CSS_TYPE_SETUP`, `NON_PICTURE_TYPES`
|
||||
- Import and register from `color-strips-music-sync.ts`
|
||||
|
||||
**Files: `en.json`, `ru.json`, `zh.json`**
|
||||
- Add i18n keys for type, visualization modes, all field labels
|
||||
|
||||
## Phase 6: Testing
|
||||
|
||||
**File: `server/tests/core/audio/test_music_analyzer.py`** (new)
|
||||
- BPM estimation from regular beats (within 5 BPM accuracy)
|
||||
- BPM handles no beats gracefully
|
||||
- Beat phase progression
|
||||
- Energy envelope attack/release
|
||||
- Frequency band splitting
|
||||
- Drop detection state machine transitions
|
||||
- No false drops on steady signal
|
||||
|
||||
**File: `server/tests/core/processing/test_music_sync_stream.py`** (new)
|
||||
- Stream lifecycle (start/stop)
|
||||
- Produces valid colors
|
||||
- Hot-update parameters
|
||||
- Auto-size from device
|
||||
- All 4 visualization modes produce valid (n,3) uint8 arrays
|
||||
- Mirror mode symmetry
|
||||
|
||||
**Storage + API tests:**
|
||||
- `from_dict` roundtrip
|
||||
- CRUD via test client
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
- **BPM accuracy** — Median-of-recent-IBIs is robust for dance/electronic. For ambient, BPM noisy but `energy_gradient` and `spectrum_bands` don't depend on BPM
|
||||
- **Drop detection false positives** — Require minimum energy threshold + sustained increase before buildup state
|
||||
- **Frontend file size** — Extract to `color-strips-music-sync.ts` (following composite/notification pattern)
|
||||
- **Strobe photosensitivity** — Cap at 10 Hz (below 15-25 Hz danger zone), add UI warning
|
||||
- **Thread safety** — `MusicAnalyzer` owned exclusively by one stream thread, no shared access
|
||||
|
||||
## Dependency Order
|
||||
|
||||
`math_wave` should be implemented first (simpler, no audio dependency), then `music_sync`.
|
||||
+22
-22
@@ -1,41 +1,41 @@
|
||||
# WLED Screen Controller — Environment Variables
|
||||
# LedGrab — Environment Variables
|
||||
# Copy this file to .env and adjust values as needed.
|
||||
# All variables use the WLED_ prefix with __ (double underscore) as the nesting delimiter.
|
||||
# All variables use the LEDGRAB_ prefix with __ (double underscore) as the nesting delimiter.
|
||||
|
||||
# ── Server ──────────────────────────────────────────────
|
||||
# WLED_SERVER__HOST=0.0.0.0 # Listen address (default: 0.0.0.0)
|
||||
# WLED_SERVER__PORT=8080 # Listen port (default: 8080)
|
||||
# WLED_SERVER__LOG_LEVEL=INFO # Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
|
||||
# WLED_SERVER__CORS_ORIGINS=["*"] # JSON array of allowed CORS origins
|
||||
# LEDGRAB_SERVER__HOST=0.0.0.0 # Listen address (default: 0.0.0.0)
|
||||
# LEDGRAB_SERVER__PORT=8080 # Listen port (default: 8080)
|
||||
# LEDGRAB_SERVER__LOG_LEVEL=INFO # Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
|
||||
# LEDGRAB_SERVER__CORS_ORIGINS=["*"] # JSON array of allowed CORS origins
|
||||
|
||||
# ── Authentication ──────────────────────────────────────
|
||||
# API keys are required. Format: JSON object {"label": "key"}.
|
||||
# WLED_AUTH__API_KEYS={"dev": "development-key-change-in-production"}
|
||||
# LEDGRAB_AUTH__API_KEYS={"dev": "development-key-change-in-production"}
|
||||
|
||||
# ── Storage ────────────────────────────────────────────
|
||||
# All data is stored in a single SQLite database.
|
||||
# WLED_STORAGE__DATABASE_FILE=data/ledgrab.db
|
||||
# LEDGRAB_STORAGE__DATABASE_FILE=data/ledgrab.db
|
||||
|
||||
# ── MQTT (optional) ────────────────────────────────────
|
||||
# WLED_MQTT__ENABLED=false
|
||||
# WLED_MQTT__BROKER_HOST=localhost
|
||||
# WLED_MQTT__BROKER_PORT=1883
|
||||
# WLED_MQTT__USERNAME=
|
||||
# WLED_MQTT__PASSWORD=
|
||||
# WLED_MQTT__CLIENT_ID=ledgrab
|
||||
# WLED_MQTT__BASE_TOPIC=ledgrab
|
||||
# LEDGRAB_MQTT__ENABLED=false
|
||||
# LEDGRAB_MQTT__BROKER_HOST=localhost
|
||||
# LEDGRAB_MQTT__BROKER_PORT=1883
|
||||
# LEDGRAB_MQTT__USERNAME=
|
||||
# LEDGRAB_MQTT__PASSWORD=
|
||||
# LEDGRAB_MQTT__CLIENT_ID=ledgrab
|
||||
# LEDGRAB_MQTT__BASE_TOPIC=ledgrab
|
||||
|
||||
# ── Logging ─────────────────────────────────────────────
|
||||
# WLED_LOGGING__FORMAT=json # json or text (default: json)
|
||||
# WLED_LOGGING__FILE=logs/wled_controller.log
|
||||
# WLED_LOGGING__MAX_SIZE_MB=100
|
||||
# WLED_LOGGING__BACKUP_COUNT=5
|
||||
# LEDGRAB_LOGGING__FORMAT=json # json or text (default: json)
|
||||
# LEDGRAB_LOGGING__FILE=logs/wled_controller.log
|
||||
# LEDGRAB_LOGGING__MAX_SIZE_MB=100
|
||||
# LEDGRAB_LOGGING__BACKUP_COUNT=5
|
||||
|
||||
# ── Demo mode ───────────────────────────────────────────
|
||||
# WLED_DEMO=false # Enable demo mode (uses data/demo/ directory)
|
||||
# LEDGRAB_DEMO=false # Enable demo mode (uses data/demo/ directory)
|
||||
|
||||
# ── Config file override ───────────────────────────────
|
||||
# WLED_CONFIG_PATH= # Absolute path to a YAML config file (overrides all above)
|
||||
# LEDGRAB_CONFIG_PATH= # Absolute path to a YAML config file (overrides all above)
|
||||
|
||||
# ── Docker Compose extras (not part of WLED_ prefix) ───
|
||||
# ── Docker Compose extras (not part of LEDGRAB_ prefix) ───
|
||||
# DISPLAY=:0 # X11 display for Linux screen capture
|
||||
|
||||
+10
-10
@@ -1,15 +1,15 @@
|
||||
# Claude Instructions for WLED Screen Controller Server
|
||||
# Claude Instructions for LedGrab Server
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `src/wled_controller/main.py` — FastAPI application entry point
|
||||
- `src/wled_controller/api/routes/` — REST API endpoints (one file per entity)
|
||||
- `src/wled_controller/api/schemas/` — Pydantic request/response models (one file per entity)
|
||||
- `src/wled_controller/core/` — Core business logic (capture, devices, audio, processing, automations)
|
||||
- `src/wled_controller/storage/` — Data models (dataclasses) and JSON persistence stores
|
||||
- `src/wled_controller/utils/` — Utility functions (logging, monitor detection, SSRF validation, sound playback)
|
||||
- `src/wled_controller/static/` — Frontend files (TypeScript, CSS, locales)
|
||||
- `src/wled_controller/templates/` — Jinja2 HTML templates
|
||||
- `src/ledgrab/main.py` — FastAPI application entry point
|
||||
- `src/ledgrab/api/routes/` — REST API endpoints (one file per entity)
|
||||
- `src/ledgrab/api/schemas/` — Pydantic request/response models (one file per entity)
|
||||
- `src/ledgrab/core/` — Core business logic (capture, devices, audio, processing, automations)
|
||||
- `src/ledgrab/storage/` — Data models (dataclasses) and JSON persistence stores
|
||||
- `src/ledgrab/utils/` — Utility functions (logging, monitor detection, SSRF validation, sound playback)
|
||||
- `src/ledgrab/static/` — Frontend files (TypeScript, CSS, locales)
|
||||
- `src/ledgrab/templates/` — Jinja2 HTML templates
|
||||
- `config/` — Configuration files (YAML)
|
||||
- `data/` — Runtime data (JSON stores, persisted state)
|
||||
|
||||
@@ -22,7 +22,7 @@ Each entity follows: dataclass model (`storage/`) + JSON store (`storage/*_store
|
||||
Server uses API key authentication via Bearer token in `Authorization` header.
|
||||
|
||||
- Config: `config/default_config.yaml` under `auth.api_keys`
|
||||
- Env var: `WLED_AUTH__API_KEYS`
|
||||
- Env var: `LEDGRAB_AUTH__API_KEYS`
|
||||
- When `api_keys` is empty (default), auth is disabled — all endpoints are open
|
||||
- To enable auth, add key entries (e.g. `dev: "your-secret-key"`)
|
||||
|
||||
|
||||
+7
-7
@@ -4,7 +4,7 @@ WORKDIR /build
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts
|
||||
COPY esbuild.mjs tsconfig.json ./
|
||||
COPY src/wled_controller/static/ ./src/wled_controller/static/
|
||||
COPY src/ledgrab/static/ ./src/ledgrab/static/
|
||||
RUN npm run build
|
||||
|
||||
## Stage 2: Python application
|
||||
@@ -16,8 +16,8 @@ LABEL maintainer="Alexei Dolgolyov <dolgolyov.alexei@gmail.com>"
|
||||
LABEL org.opencontainers.image.title="LED Grab"
|
||||
LABEL org.opencontainers.image.description="Ambient lighting system that captures screen content and drives LED strips in real time"
|
||||
LABEL org.opencontainers.image.version="${APP_VERSION}"
|
||||
LABEL org.opencontainers.image.url="https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
||||
LABEL org.opencontainers.image.source="https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
||||
LABEL org.opencontainers.image.url="https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
|
||||
LABEL org.opencontainers.image.source="https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
@@ -37,16 +37,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# The real source is copied afterward, keeping the dep layer cached.
|
||||
COPY pyproject.toml .
|
||||
RUN sed -i "s/^version = .*/version = \"${APP_VERSION}\"/" pyproject.toml \
|
||||
&& mkdir -p src/wled_controller && touch src/wled_controller/__init__.py \
|
||||
&& mkdir -p src/ledgrab && touch src/ledgrab/__init__.py \
|
||||
&& pip install --no-cache-dir ".[notifications]" \
|
||||
&& rm -rf src/wled_controller
|
||||
&& rm -rf src/ledgrab
|
||||
|
||||
# Copy source code and config (invalidates cache only when source changes)
|
||||
COPY src/ ./src/
|
||||
COPY config/ ./config/
|
||||
|
||||
# Copy built frontend bundle from stage 1
|
||||
COPY --from=frontend /build/src/wled_controller/static/dist/ ./src/wled_controller/static/dist/
|
||||
COPY --from=frontend /build/src/ledgrab/static/dist/ ./src/ledgrab/static/dist/
|
||||
|
||||
# Create non-root user for security
|
||||
RUN groupadd --gid 1000 ledgrab \
|
||||
@@ -67,4 +67,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||
ENV PYTHONPATH=/app/src
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "wled_controller.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
CMD ["uvicorn", "ledgrab.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
|
||||
+11
-11
@@ -1,4 +1,4 @@
|
||||
# WLED Screen Controller - Server
|
||||
# LedGrab - Server
|
||||
|
||||
High-performance FastAPI server that captures screen content and controls WLED devices for ambient lighting.
|
||||
|
||||
@@ -47,7 +47,7 @@ export PYTHONPATH=$(pwd)/src # Linux/Mac
|
||||
set PYTHONPATH=%CD%\src # Windows
|
||||
|
||||
# Run server
|
||||
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||
uvicorn ledgrab.main:app --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
## Installation
|
||||
@@ -85,20 +85,20 @@ storage:
|
||||
|
||||
logging:
|
||||
format: "json"
|
||||
file: "logs/wled_controller.log"
|
||||
file: "logs/ledgrab.log"
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Server configuration
|
||||
export WLED_SERVER__HOST="0.0.0.0"
|
||||
export WLED_SERVER__PORT=8080
|
||||
export WLED_SERVER__LOG_LEVEL="INFO"
|
||||
export LEDGRAB_SERVER__HOST="0.0.0.0"
|
||||
export LEDGRAB_SERVER__PORT=8080
|
||||
export LEDGRAB_SERVER__LOG_LEVEL="INFO"
|
||||
|
||||
# Processing configuration
|
||||
export WLED_PROCESSING__DEFAULT_FPS=30
|
||||
export WLED_PROCESSING__BORDER_WIDTH=10
|
||||
export LEDGRAB_PROCESSING__DEFAULT_FPS=30
|
||||
export LEDGRAB_PROCESSING__BORDER_WIDTH=10
|
||||
|
||||
# WLED configuration
|
||||
export WLED_WLED__TIMEOUT=5
|
||||
@@ -147,7 +147,7 @@ curl http://localhost:8080/api/v1/devices/{device_id}/state
|
||||
pytest
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=wled_controller --cov-report=html
|
||||
pytest --cov=ledgrab --cov-report=html
|
||||
|
||||
# Run specific test
|
||||
pytest tests/test_screen_capture.py -v
|
||||
@@ -158,7 +158,7 @@ pytest tests/test_screen_capture.py -v
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/wled_controller/
|
||||
src/ledgrab/
|
||||
├── main.py # FastAPI application
|
||||
├── config.py # Configuration
|
||||
├── api/ # API routes
|
||||
@@ -188,4 +188,4 @@ MIT - see [../LICENSE](../LICENSE)
|
||||
## Support
|
||||
|
||||
- 📖 [Full Documentation](../docs/)
|
||||
- 🐛 [Issues](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues)
|
||||
- 🐛 [Issues](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/issues)
|
||||
|
||||
@@ -8,14 +8,22 @@ server:
|
||||
- "http://localhost:8080"
|
||||
|
||||
auth:
|
||||
# API keys — when empty, authentication is disabled (open access).
|
||||
# To enable auth, add one or more label: "api-key" entries.
|
||||
# API keys — required for any non-loopback (LAN) request.
|
||||
# When empty:
|
||||
# - loopback (127.0.0.1, ::1, localhost) requests are allowed anonymously
|
||||
# - LAN requests are REJECTED with 401 (security default)
|
||||
# To enable LAN access, add one or more label: "api-key" entries below
|
||||
# and send `Authorization: Bearer <api-key>` with each request.
|
||||
# Generate secure keys: openssl rand -hex 32
|
||||
api_keys:
|
||||
dev: "development-key-change-in-production"
|
||||
|
||||
storage:
|
||||
database_file: "data/ledgrab.db"
|
||||
# Storage paths default to ./data relative to the server's working directory.
|
||||
# Set LEDGRAB_DATA_DIR in the environment to point at a different data root
|
||||
# (the whole dir — both the database and assets), or uncomment the block
|
||||
# below to pin an absolute database file.
|
||||
# storage:
|
||||
# database_file: "/absolute/path/to/ledgrab.db"
|
||||
|
||||
mqtt:
|
||||
enabled: false
|
||||
@@ -28,6 +36,13 @@ mqtt:
|
||||
|
||||
logging:
|
||||
format: "json" # json or text
|
||||
file: "logs/wled_controller.log"
|
||||
file: "logs/ledgrab.log"
|
||||
max_size_mb: 100
|
||||
backup_count: 5
|
||||
|
||||
updates:
|
||||
# When false (default), updates without a published sha256 checksum
|
||||
# (sibling .sha256 asset OR 64-hex string in release body) are aborted
|
||||
# before any installer/extractor runs. NEVER set true unless you
|
||||
# control the release server end-to-end.
|
||||
allow_unchecked: false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Demo mode configuration
|
||||
# Loaded automatically when WLED_DEMO=true is set.
|
||||
# Loaded automatically when LEDGRAB_DEMO=true is set.
|
||||
# Uses isolated data directory (data/demo/) and a pre-configured API key
|
||||
# so the demo works out of the box with zero setup.
|
||||
|
||||
@@ -26,6 +26,6 @@ mqtt:
|
||||
|
||||
logging:
|
||||
format: "text"
|
||||
file: "logs/wled_controller.log"
|
||||
file: "logs/ledgrab.log"
|
||||
max_size_mb: 100
|
||||
backup_count: 5
|
||||
|
||||
@@ -15,6 +15,6 @@ storage:
|
||||
|
||||
logging:
|
||||
format: "text"
|
||||
file: "logs/wled_test.log"
|
||||
file: "logs/ledgrab_test.log"
|
||||
max_size_mb: 10
|
||||
backup_count: 2
|
||||
|
||||
+17
-17
@@ -1,14 +1,14 @@
|
||||
services:
|
||||
wled-controller:
|
||||
ledgrab:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: ledgrab:latest
|
||||
container_name: wled-screen-controller
|
||||
container_name: ledgrab
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "${WLED_PORT:-8080}:8080"
|
||||
- "${LEDGRAB_PORT:-8080}:8080"
|
||||
|
||||
volumes:
|
||||
# Persist device data and configuration across restarts
|
||||
@@ -22,37 +22,37 @@ services:
|
||||
environment:
|
||||
## Server
|
||||
# Bind address and port (usually no need to change)
|
||||
- WLED_SERVER__HOST=0.0.0.0
|
||||
- WLED_SERVER__PORT=8080
|
||||
- WLED_SERVER__LOG_LEVEL=INFO
|
||||
- LEDGRAB_SERVER__HOST=0.0.0.0
|
||||
- LEDGRAB_SERVER__PORT=8080
|
||||
- LEDGRAB_SERVER__LOG_LEVEL=INFO
|
||||
# CORS origins — add your LAN IP for remote access, e.g.:
|
||||
# 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"]
|
||||
|
||||
## Auth
|
||||
# Override the default API key (STRONGLY recommended for production):
|
||||
# WLED_AUTH__API_KEYS__main=your-secure-key-here
|
||||
# LEDGRAB_AUTH__API_KEYS__main=your-secure-key-here
|
||||
# Generate a key: openssl rand -hex 32
|
||||
|
||||
## Display (Linux X11 only)
|
||||
- DISPLAY=${DISPLAY:-:0}
|
||||
|
||||
## Processing defaults
|
||||
#- WLED_PROCESSING__DEFAULT_FPS=30
|
||||
#- WLED_PROCESSING__BORDER_WIDTH=10
|
||||
#- LEDGRAB_PROCESSING__DEFAULT_FPS=30
|
||||
#- LEDGRAB_PROCESSING__BORDER_WIDTH=10
|
||||
|
||||
## MQTT (optional — for Home Assistant auto-discovery)
|
||||
#- WLED_MQTT__ENABLED=true
|
||||
#- WLED_MQTT__BROKER_HOST=192.168.1.2
|
||||
#- WLED_MQTT__BROKER_PORT=1883
|
||||
#- WLED_MQTT__USERNAME=
|
||||
#- WLED_MQTT__PASSWORD=
|
||||
#- LEDGRAB_MQTT__ENABLED=true
|
||||
#- LEDGRAB_MQTT__BROKER_HOST=192.168.1.2
|
||||
#- LEDGRAB_MQTT__BROKER_PORT=1883
|
||||
#- LEDGRAB_MQTT__USERNAME=
|
||||
#- LEDGRAB_MQTT__PASSWORD=
|
||||
|
||||
# Uncomment for Linux screen capture (requires host network for X11 access)
|
||||
# network_mode: host
|
||||
|
||||
networks:
|
||||
- wled-network
|
||||
- ledgrab-network
|
||||
|
||||
networks:
|
||||
wled-network:
|
||||
ledgrab-network:
|
||||
driver: bridge
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# API Authentication Guide
|
||||
|
||||
WLED Screen Controller **requires** API key authentication for all API endpoints. This ensures your server is secure and all access is properly authenticated and audited.
|
||||
LedGrab **requires** API key authentication for all API endpoints. This ensures your server is secure and all access is properly authenticated and audited.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -66,7 +66,7 @@ curl -H "Authorization: Bearer your-api-key-here" \
|
||||
The integration will prompt for an API key during setup if authentication is enabled. You can also configure it in `configuration.yaml`:
|
||||
|
||||
```yaml
|
||||
wled_screen_controller:
|
||||
ledgrab:
|
||||
server_url: "http://192.168.1.100:8080"
|
||||
api_key: "your-api-key-here" # Optional, only if auth is enabled
|
||||
```
|
||||
@@ -168,8 +168,8 @@ export WLED_API_KEY_2="$(openssl rand -hex 32)"
|
||||
services:
|
||||
wled-controller:
|
||||
environment:
|
||||
- WLED_AUTH__ENABLED=true
|
||||
- WLED_AUTH__API_KEYS__0=your-key-here
|
||||
- LEDGRAB_AUTH__ENABLED=true
|
||||
- LEDGRAB_AUTH__API_KEYS__0=your-key-here
|
||||
```
|
||||
|
||||
Or use Docker secrets for better security.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
const srcDir = 'src/wled_controller/static';
|
||||
const srcDir = 'src/ledgrab/static';
|
||||
const outDir = `${srcDir}/dist`;
|
||||
|
||||
const watch = process.argv.includes('--watch');
|
||||
|
||||
+18
-7
@@ -3,8 +3,8 @@ requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "wled-screen-controller"
|
||||
version = "0.3.0"
|
||||
name = "ledgrab"
|
||||
version = "0.4.0"
|
||||
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
|
||||
authors = [
|
||||
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
||||
@@ -25,6 +25,7 @@ classifiers = [
|
||||
dependencies = [
|
||||
"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",
|
||||
@@ -66,6 +67,10 @@ camera = [
|
||||
# opencv-python-headless is now a core dependency (used for image encoding)
|
||||
# camera extra kept for backwards compatibility
|
||||
]
|
||||
# High-performance Android capture via scrcpy H.264 streaming
|
||||
scrcpy = [
|
||||
"scrcpy-client>=0.5.0",
|
||||
]
|
||||
# OS notification capture (winrt packages are ~2.5MB total vs winsdk's ~35MB)
|
||||
notifications = [
|
||||
"winrt-Windows.UI.Notifications>=3.0.0; sys_platform == 'win32'",
|
||||
@@ -81,12 +86,18 @@ perf = [
|
||||
"bettercam>=1.0.0; sys_platform == 'win32'",
|
||||
"windows-capture>=1.5.0; sys_platform == 'win32'",
|
||||
]
|
||||
# BLE LED controllers (SP110E, Triones/HappyLighting, Zengge/iLightsIn, Govee).
|
||||
# Desktop-only — bleak does not support Android; Chaquopy build must NOT list
|
||||
# bleak. Imports are guarded with try/except ImportError on all BLE modules.
|
||||
ble = [
|
||||
"bleak>=0.22",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
||||
Repository = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
||||
Documentation = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/src/branch/master/INSTALLATION.md"
|
||||
Issues = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues"
|
||||
Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
|
||||
Repository = "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
|
||||
Documentation = "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/src/branch/master/INSTALLATION.md"
|
||||
Issues = "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/issues"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
@@ -97,7 +108,7 @@ where = ["src"]
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
addopts = "-v --cov=wled_controller --cov-report=html --cov-report=term"
|
||||
addopts = "-v --cov=ledgrab --cov-report=html --cov-report=term"
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
|
||||
+307
-71
@@ -1,78 +1,207 @@
|
||||
# Restart the WLED Screen Controller server
|
||||
# Uses graceful shutdown first (lets the server persist data to disk),
|
||||
# then force-kills as a fallback.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Restart a LedGrab Python server (real or demo) reliably.
|
||||
|
||||
$serverRoot = 'c:\Users\Alexei\Documents\wled-screen-controller\server'
|
||||
.DESCRIPTION
|
||||
Gracefully asks the running instance to shut down via its HTTP API, waits
|
||||
for the port to free, then launches a detached replacement and polls the
|
||||
port until it is actually accepting connections.
|
||||
|
||||
# Read API key from config for authenticated shutdown request
|
||||
$configPath = Join-Path $serverRoot 'config\default_config.yaml'
|
||||
$apiKey = $null
|
||||
if (Test-Path $configPath) {
|
||||
$inKeys = $false
|
||||
foreach ($line in Get-Content $configPath) {
|
||||
if ($line -match '^\s*api_keys:') { $inKeys = $true; continue }
|
||||
if ($inKeys -and $line -match '^\s+\w+:\s*"(.+)"') {
|
||||
$apiKey = $Matches[1]; break
|
||||
The script is parameterised so it works for the real server (default:
|
||||
port 8080, module `ledgrab`), the demo server, and any future variant —
|
||||
no code edits required to point it somewhere else.
|
||||
|
||||
.PARAMETER Port
|
||||
TCP port the server binds. Used both to locate the running process and
|
||||
to poll startup readiness.
|
||||
|
||||
.PARAMETER Module
|
||||
Python `-m` module to launch. Also used as a substring match when
|
||||
identifying which python.exe processes belong to this server so we don't
|
||||
kill unrelated Python instances.
|
||||
|
||||
.PARAMETER ServerRoot
|
||||
Working directory for the server process. Defaults to the directory that
|
||||
contains this script.
|
||||
|
||||
.PARAMETER ConfigPath
|
||||
Path (relative to -ServerRoot or absolute) to the YAML config the running
|
||||
server is using. Used only to read the API key for the graceful-shutdown
|
||||
request. If empty or missing we skip graceful shutdown and force-kill.
|
||||
|
||||
.PARAMETER StartupTimeoutSec
|
||||
How long to poll for the new server to start accepting connections.
|
||||
|
||||
.PARAMETER ShutdownTimeoutSec
|
||||
How long to wait for the graceful-shutdown API call to cause the running
|
||||
process to exit before force-killing it.
|
||||
|
||||
.PARAMETER SkipBrowser
|
||||
Set LEDGRAB_RESTART=1 in the child env so the app doesn't open a browser
|
||||
tab on startup. On by default — pass -SkipBrowser:$false to allow it.
|
||||
|
||||
.PARAMETER Quiet
|
||||
Suppress progress messages; only emit warnings/errors.
|
||||
|
||||
.EXAMPLE
|
||||
# Restart the real server (default invocation)
|
||||
powershell -ExecutionPolicy Bypass -File restart.ps1
|
||||
|
||||
.EXAMPLE
|
||||
# Restart the demo server on port 8081
|
||||
powershell -ExecutionPolicy Bypass -File restart.ps1 `
|
||||
-Port 8081 -Module ledgrab.demo -ConfigPath 'config\demo_config.yaml'
|
||||
|
||||
.NOTES
|
||||
Exit codes:
|
||||
0 — server is up and accepting connections on the target port
|
||||
1 — startup timed out; process may or may not be running
|
||||
2 — could not locate a Python interpreter
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$Port = 8080,
|
||||
[string]$Module = 'ledgrab',
|
||||
[string]$ServerRoot = '',
|
||||
[string]$ConfigPath = 'config\default_config.yaml',
|
||||
[int]$StartupTimeoutSec = 30,
|
||||
[int]$ShutdownTimeoutSec = 15,
|
||||
[string]$PythonExe = '',
|
||||
[string]$PythonVersion = '3.13',
|
||||
[switch]$SkipBrowser = $true,
|
||||
[switch]$Quiet
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-Info {
|
||||
param([string]$Message)
|
||||
if (-not $Quiet) { Write-Host $Message }
|
||||
}
|
||||
|
||||
# ---- Resolve paths ---------------------------------------------------------
|
||||
|
||||
# PS 5.1 doesn't expand $PSScriptRoot at param-binding time, so apply it here.
|
||||
if (-not $ServerRoot) { $ServerRoot = $PSScriptRoot }
|
||||
if (-not $ServerRoot) {
|
||||
Write-Error 'ServerRoot not provided and $PSScriptRoot is unavailable'
|
||||
exit 2
|
||||
}
|
||||
if (-not (Test-Path $ServerRoot)) {
|
||||
Write-Error "ServerRoot '$ServerRoot' does not exist"
|
||||
exit 2
|
||||
}
|
||||
$ServerRoot = (Resolve-Path $ServerRoot).Path
|
||||
|
||||
$resolvedConfig = $null
|
||||
if ($ConfigPath) {
|
||||
$candidate = if ([IO.Path]::IsPathRooted($ConfigPath)) {
|
||||
$ConfigPath
|
||||
} else {
|
||||
Join-Path $ServerRoot $ConfigPath
|
||||
}
|
||||
if (Test-Path $candidate) { $resolvedConfig = $candidate }
|
||||
}
|
||||
|
||||
# ---- Locate the running server ---------------------------------------------
|
||||
|
||||
function Get-ServerProcesses {
|
||||
param([string]$ModuleName, [string]$Root)
|
||||
# Match python.exe processes whose command line references this module AND
|
||||
# whose cwd (via command line fragment) looks like it's running from this
|
||||
# server root. Excludes unrelated python.exe (VS Code extensions, isort,
|
||||
# pip tooling, etc.) by requiring a module reference.
|
||||
$rootPattern = [regex]::Escape($Root)
|
||||
Get-CimInstance Win32_Process -Filter "Name='python.exe'" -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
$cl = $_.CommandLine
|
||||
if (-not $cl) { return $false }
|
||||
# Must launch the target module via `-m <Module>` or an exact token
|
||||
$launchesModule = $cl -match ('-m\s+' + [regex]::Escape($ModuleName) + '(\s|$|\.)')
|
||||
if (-not $launchesModule) { return $false }
|
||||
# Exclude obvious tooling false-positives
|
||||
if ($cl -match '(vscode|isort|pip[-\s]|flake8|ruff|mypy|pylint|black)') {
|
||||
return $false
|
||||
}
|
||||
return $true
|
||||
}
|
||||
if ($inKeys -and $line -match '^\S') { break } # left the api_keys block
|
||||
}
|
||||
|
||||
function Test-PortOpen {
|
||||
param([int]$Port)
|
||||
try {
|
||||
$listener = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop
|
||||
return [bool]$listener
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Find running server processes
|
||||
$procs = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
|
||||
Where-Object { $_.CommandLine -like '*wled_controller*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
|
||||
$existing = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot
|
||||
|
||||
if ($procs) {
|
||||
# Step 1: Request graceful shutdown via API (triggers lifespan shutdown + store save)
|
||||
$shutdownOk = $false
|
||||
if ($apiKey) {
|
||||
Write-Host "Requesting graceful shutdown..."
|
||||
try {
|
||||
$headers = @{ Authorization = "Bearer $apiKey" }
|
||||
Invoke-RestMethod -Uri 'http://localhost:8080/api/v1/system/shutdown' `
|
||||
-Method Post -Headers $headers -TimeoutSec 5 -ErrorAction Stop | Out-Null
|
||||
$shutdownOk = $true
|
||||
} catch {
|
||||
Write-Host " API shutdown failed ($($_.Exception.Message)), falling back to process kill"
|
||||
# ---- Graceful shutdown (if the target is currently up) ---------------------
|
||||
|
||||
if ($existing) {
|
||||
$apiKey = $null
|
||||
if ($resolvedConfig) {
|
||||
# Pull the first api_keys entry — good enough for the local shutdown
|
||||
# endpoint; production deploys don't use this script.
|
||||
$inKeys = $false
|
||||
foreach ($line in Get-Content $resolvedConfig) {
|
||||
if ($line -match '^\s*api_keys:') { $inKeys = $true; continue }
|
||||
if ($inKeys -and $line -match '^\s+\w+:\s*"(.+)"') {
|
||||
$apiKey = $Matches[1]; break
|
||||
}
|
||||
if ($inKeys -and $line -match '^\S') { break }
|
||||
}
|
||||
}
|
||||
|
||||
if ($shutdownOk) {
|
||||
# Step 2: Wait for the server to exit gracefully (up to 15 seconds)
|
||||
# The server needs time to stop processors, disconnect devices, and persist stores.
|
||||
Write-Host "Waiting for graceful shutdown..."
|
||||
$shutdownRequested = $false
|
||||
if ($apiKey) {
|
||||
Write-Info 'Requesting graceful shutdown...'
|
||||
try {
|
||||
$headers = @{ Authorization = "Bearer $apiKey" }
|
||||
Invoke-RestMethod -Uri "http://localhost:$Port/api/v1/system/shutdown" `
|
||||
-Method Post -Headers $headers -TimeoutSec 5 -ErrorAction Stop | Out-Null
|
||||
$shutdownRequested = $true
|
||||
} catch {
|
||||
Write-Info " API shutdown failed ($($_.Exception.Message)); will force-kill"
|
||||
}
|
||||
}
|
||||
|
||||
if ($shutdownRequested) {
|
||||
Write-Info 'Waiting for graceful shutdown...'
|
||||
$waited = 0
|
||||
while ($waited -lt 15) {
|
||||
while ($waited -lt $ShutdownTimeoutSec) {
|
||||
Start-Sleep -Seconds 1
|
||||
$waited++
|
||||
$still = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
|
||||
Where-Object { $_.CommandLine -like '*wled_controller*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
|
||||
if (-not $still) {
|
||||
Write-Host " Server exited cleanly after ${waited}s"
|
||||
if (-not (Get-ServerProcesses -ModuleName $Module -Root $ServerRoot)) {
|
||||
Write-Info " Exited cleanly after ${waited}s"
|
||||
break
|
||||
}
|
||||
}
|
||||
# Step 3: Force-kill stragglers
|
||||
$still = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
|
||||
Where-Object { $_.CommandLine -like '*wled_controller*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
|
||||
if ($still) {
|
||||
Write-Host " Force-killing remaining processes..."
|
||||
foreach ($p in $still) {
|
||||
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
} else {
|
||||
# No API key or API call failed — force-kill directly
|
||||
foreach ($p in $procs) {
|
||||
Write-Host "Stopping server (PID $($p.ProcessId))..."
|
||||
}
|
||||
|
||||
$still = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot
|
||||
if ($still) {
|
||||
Write-Info ' Force-killing remaining processes...'
|
||||
foreach ($p in $still) {
|
||||
Write-Info " Stop PID $($p.ProcessId)"
|
||||
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
# Wait for Windows to release the TCP socket before we rebind. A fixed
|
||||
# 1–2 s sleep isn't enough on machines where the kernel lingers in
|
||||
# CLOSE_WAIT; poll the port state instead.
|
||||
$portDeadline = (Get-Date).AddSeconds(10)
|
||||
while ((Get-Date) -lt $portDeadline -and (Test-PortOpen -Port $Port)) {
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
}
|
||||
|
||||
# Merge registry PATH with current PATH so newly-installed tools (e.g. scrcpy) are visible
|
||||
# ---- Merge per-user PATH (captures tools installed after the shell started) ----
|
||||
|
||||
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User')
|
||||
if ($regUser) {
|
||||
$currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') }
|
||||
@@ -83,25 +212,132 @@ if ($regUser) {
|
||||
}
|
||||
}
|
||||
|
||||
# Start server detached (set WLED_RESTART=1 to skip browser open)
|
||||
Write-Host "Starting server..."
|
||||
$env:WLED_RESTART = "1"
|
||||
$pythonExe = (Get-Command python -ErrorAction SilentlyContinue).Source
|
||||
if (-not $pythonExe) {
|
||||
# Fallback to known install location
|
||||
$pythonExe = "$env:LOCALAPPDATA\Programs\Python\Python313\python.exe"
|
||||
# ---- Locate a Python interpreter -------------------------------------------
|
||||
|
||||
# We need the Python that actually has the target module installed. Naively
|
||||
# resolving `python` on PATH can pick up 3.11 or another version that doesn't
|
||||
# have `ledgrab` in its site-packages, so prefer an explicit interpreter in
|
||||
# this priority order:
|
||||
# 1. -PythonExe (caller override)
|
||||
# 2. `py -<Version>` via the Windows Python launcher
|
||||
# 3. A Python<Version> install under %LOCALAPPDATA%\Programs\Python
|
||||
# 4. `python` on PATH (last-resort fallback)
|
||||
|
||||
function Test-HasModule {
|
||||
param([string]$Exe, [string]$ModuleName)
|
||||
if (-not $Exe -or -not (Test-Path $Exe)) { return $false }
|
||||
& $Exe -c "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('$ModuleName') else 1)" 2>$null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
}
|
||||
Start-Process -FilePath $pythonExe -ArgumentList '-m', 'wled_controller' `
|
||||
-WorkingDirectory $serverRoot `
|
||||
-WindowStyle Hidden
|
||||
|
||||
Start-Sleep -Seconds 3
|
||||
$resolvedPython = $null
|
||||
$launchArgs = @()
|
||||
|
||||
# Verify it's running
|
||||
$check = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
|
||||
Where-Object { $_.CommandLine -like '*wled_controller*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
|
||||
if ($check) {
|
||||
Write-Host "Server started (PID $($check[0].ProcessId))"
|
||||
if ($PythonExe) {
|
||||
if (-not (Test-Path $PythonExe)) {
|
||||
Write-Error "PythonExe '$PythonExe' does not exist"
|
||||
exit 2
|
||||
}
|
||||
$resolvedPython = (Resolve-Path $PythonExe).Path
|
||||
} else {
|
||||
Write-Host "WARNING: Server does not appear to be running!"
|
||||
# Try `py -<version>`
|
||||
$pyLauncher = (Get-Command py -ErrorAction SilentlyContinue).Source
|
||||
if ($pyLauncher) {
|
||||
$probe = & $pyLauncher "-$PythonVersion" -c "import sys; print(sys.executable)" 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $probe) {
|
||||
$resolvedPython = $pyLauncher
|
||||
$launchArgs = @("-$PythonVersion")
|
||||
}
|
||||
}
|
||||
# Fall back to a known install path for that version
|
||||
if (-not $resolvedPython) {
|
||||
$verTag = $PythonVersion -replace '\.', ''
|
||||
$candidate = Join-Path $env:LOCALAPPDATA "Programs\Python\Python$verTag\python.exe"
|
||||
if (Test-Path $candidate) { $resolvedPython = $candidate }
|
||||
}
|
||||
# Last resort: plain `python` on PATH
|
||||
if (-not $resolvedPython) {
|
||||
$onPath = (Get-Command python -ErrorAction SilentlyContinue).Source
|
||||
if ($onPath) { $resolvedPython = $onPath }
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $resolvedPython) {
|
||||
Write-Error "No Python $PythonVersion interpreter found (tried: -PythonExe, py -$PythonVersion, %LOCALAPPDATA%\Programs\Python\Python*, PATH)"
|
||||
exit 2
|
||||
}
|
||||
|
||||
# Verify the module is actually importable with the chosen interpreter so we
|
||||
# don't launch a process that would immediately die with "No module named X".
|
||||
# When using the `py` launcher, delegate to the versioned interpreter.
|
||||
$effectiveExe = if ($launchArgs.Count -gt 0) {
|
||||
& $resolvedPython @launchArgs -c "import sys; print(sys.executable)" 2>$null
|
||||
} else {
|
||||
$resolvedPython
|
||||
}
|
||||
|
||||
if (-not (Test-HasModule -Exe $effectiveExe -ModuleName $Module)) {
|
||||
Write-Error "Module '$Module' is not importable with $effectiveExe. Install it (e.g. pip install -e .) or pass -PythonExe pointing to the right interpreter."
|
||||
exit 2
|
||||
}
|
||||
|
||||
$pythonExe = $resolvedPython
|
||||
|
||||
# ---- Launch detached replacement -------------------------------------------
|
||||
|
||||
Write-Info "Starting $Module on port $Port..."
|
||||
if ($SkipBrowser) { $env:LEDGRAB_RESTART = '1' }
|
||||
|
||||
# Redirect the child's stdout/stderr to a log file. Without this, inheriting
|
||||
# the parent shell's handles via Start-Process -WindowStyle Hidden can cause
|
||||
# the child to exit immediately when those handles aren't real console fds
|
||||
# (e.g. when restart.ps1 is driven from WSL/Git-Bash).
|
||||
$logPath = Join-Path $env:TEMP ("ledgrab-{0}-{1}.log" -f $Module, $Port)
|
||||
$errPath = "$logPath.err"
|
||||
$argList = @()
|
||||
$argList += $launchArgs
|
||||
$argList += @('-m', $Module)
|
||||
$startedProc = Start-Process -FilePath $pythonExe `
|
||||
-ArgumentList $argList `
|
||||
-WorkingDirectory $ServerRoot `
|
||||
-WindowStyle Hidden `
|
||||
-RedirectStandardOutput $logPath `
|
||||
-RedirectStandardError $errPath `
|
||||
-PassThru
|
||||
$startedPid = $startedProc.Id
|
||||
|
||||
# ---- Poll readiness --------------------------------------------------------
|
||||
|
||||
# Port readiness is the authoritative signal — the process can be alive for
|
||||
# many seconds before uvicorn finishes binding on cold starts (store init,
|
||||
# etc.). Polling avoids spurious "not running" warnings that the old fixed
|
||||
# 3-second sleep produced.
|
||||
$deadline = (Get-Date).AddSeconds($StartupTimeoutSec)
|
||||
$ready = $false
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
# Bail early if the process has already exited — something went wrong.
|
||||
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
|
||||
if (-not $proc) { break }
|
||||
if (Test-PortOpen -Port $Port) { $ready = $true; break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
|
||||
if ($ready) {
|
||||
Write-Info "Server ready on port $Port (PID $startedPid)"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
|
||||
if (-not $proc) {
|
||||
Write-Warning "Server process $startedPid exited before binding port $Port"
|
||||
} else {
|
||||
Write-Warning "Server PID $startedPid is running but did not bind port $Port within ${StartupTimeoutSec}s"
|
||||
}
|
||||
if (Test-Path $errPath) {
|
||||
$tail = Get-Content $errPath -Tail 20 -ErrorAction SilentlyContinue
|
||||
if ($tail) {
|
||||
Write-Warning "Last stderr lines from $errPath :"
|
||||
$tail | ForEach-Object { Write-Warning " $_" }
|
||||
}
|
||||
}
|
||||
exit 1
|
||||
|
||||
+5
-5
@@ -1,25 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# Restart the WLED Screen Controller server (Linux/macOS)
|
||||
# Restart the LedGrab server (Linux/macOS)
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Stop any running instance
|
||||
PIDS=$(pgrep -f 'wled_controller\.main' 2>/dev/null || true)
|
||||
PIDS=$(pgrep -f 'ledgrab\.main' 2>/dev/null || true)
|
||||
if [ -n "$PIDS" ]; then
|
||||
echo "Stopping server (PID $PIDS)..."
|
||||
pkill -f 'wled_controller\.main' 2>/dev/null || true
|
||||
pkill -f 'ledgrab\.main' 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# Start server detached
|
||||
echo "Starting server..."
|
||||
cd "$SCRIPT_DIR"
|
||||
nohup python -m wled_controller.main > /dev/null 2>&1 &
|
||||
nohup python -m ledgrab.main > /dev/null 2>&1 &
|
||||
sleep 3
|
||||
|
||||
# Verify it's running
|
||||
NEW_PID=$(pgrep -f 'wled_controller\.main' 2>/dev/null || true)
|
||||
NEW_PID=$(pgrep -f 'ledgrab\.main' 2>/dev/null || true)
|
||||
if [ -n "$NEW_PID" ]; then
|
||||
echo "Server started (PID $NEW_PID)"
|
||||
else
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user