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

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

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

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

|
||||
|
||||
- **Network LED controllers** — WLED (HTTP/UDP, with mDNS auto-discovery), DDP (Pixelblaze, ESPixelStick, Falcon), Open Pixel Control (OPC), Art-Net / sACN (E1.31), ESP-NOW, and generic WebSocket streaming
|
||||
- **Serial / direct hardware** — Adalight (Arduino-compatible), AmbiLED, SPI-attached strips (e.g. WS2812B), and USB HID controllers
|
||||
- **Smart bulbs & panels** — Philips Hue (Entertainment API), Nanoleaf, Yeelight, WiZ, LIFX, and Govee (Wi-Fi LAN)
|
||||
- **Bluetooth LE strips** — SP110E, Triones / HappyLighting, Zengge, and Govee BLE
|
||||
- **PC peripherals** — OpenRGB, Razer Chroma, and SteelSeries GameSense (keyboards, mice, RAM, fans, etc.)
|
||||
- **Device groups** — combine multiple devices into one logical target
|
||||
- Serial port auto-detection and baud-rate configuration
|
||||
|
||||
### Color Processing
|
||||
|
||||
- Post-processing filter pipeline: brightness, gamma, saturation, color correction, auto-crop, frame interpolation, pixelation, flip
|
||||
- Post-processing filter pipeline: brightness, gamma, saturation, color correction, auto-crop, frame interpolation, pixelation, flip, and more
|
||||
- Reusable post-processing templates
|
||||
- Color strip sources: audio-reactive, pattern generator, composite layering, audio-to-color mapping
|
||||
- Color strip sources: audio-reactive, pattern generator, gradients, composite layering, and audio-to-color mapping
|
||||
- Pattern templates with customizable effects
|
||||
|
||||
### Audio Integration
|
||||
@@ -38,17 +60,20 @@ A Home Assistant integration exposes devices as entities for smart home automati
|
||||
- Multichannel audio capture from any system device (input or loopback)
|
||||
- WASAPI engine on Windows, Sounddevice (PortAudio) engine on Linux/macOS
|
||||
- Per-channel mono extraction
|
||||
- Audio-reactive color strip sources driven by frequency analysis
|
||||
- Audio filter / processing pipeline feeding audio-reactive color sources driven by frequency analysis
|
||||
|
||||
### Automation
|
||||
|
||||
- Profile engine with condition-based switching (time of day, active window, etc.)
|
||||
- Dynamic brightness value sources (schedule-based, scene-aware)
|
||||
- Key Colors (KC) targets with live WebSocket color streaming
|
||||
- Automations engine with condition-based rules — switch targets, scenes, or brightness by time of day, active window/process, MQTT, webhooks, or game events
|
||||
- Scene presets for one-click lighting changes
|
||||
- Dynamic value sources for brightness and other parameters (schedule-based, weather-based, scene-aware)
|
||||
- Weather sources, clock sync, webhooks, and inbound/outbound HTTP endpoints
|
||||
- Game integration adapters (e.g. League of Legends)
|
||||
|
||||
### Dashboard
|
||||
|
||||
- Web UI at `http://localhost:8080` — no installation needed on the client side
|
||||
- Web UI at `http://localhost:8080` — nothing to install on the client side
|
||||
- Visual node-graph editor for wiring sources → processing → targets
|
||||
- Progressive Web App (PWA) — installable on phones and tablets with offline caching
|
||||
- Responsive mobile layout with bottom tab navigation
|
||||
- Device management with auto-discovery wizard
|
||||
@@ -57,34 +82,72 @@ A Home Assistant integration exposes devices as entities for smart home automati
|
||||
- Real-time FPS, latency, and uptime charts
|
||||
- Localized in English, Russian, and Chinese
|
||||
|
||||
### Activity Log
|
||||
|
||||
The **Activity** tab is a persistent, queryable audit log of everything LedGrab has done — entity changes, auth events, device connections, and system actions.
|
||||
|
||||
- Filter by category (auth, device, entity, capture, system), severity, actor, entity type, date range, or free text
|
||||
- Live-append of new events as they happen
|
||||
- Export as CSV or JSON (authentication required)
|
||||
- Entity crosslinks navigate directly to the relevant card
|
||||
- **Retention settings** (Settings → Activity Log): configure max age, max entry count, and toggle recording on/off
|
||||
- **Clear log** (Settings → Activity Log, requires authentication) — audited: a system entry records who cleared the log and when
|
||||
|
||||
> **Note:** The Activity Log is distinct from the **debug Log Viewer** (Settings → General → Open Log Viewer). The Log Viewer is an ephemeral real-time tail of the server's Python log stream (WARNING/ERROR lines, resets on disconnect). The Activity Log is a structured, persistent SQLite-backed record of semantic application events.
|
||||
|
||||
### Home Assistant Integration
|
||||
|
||||
- HACS-compatible custom component
|
||||
- HACS-compatible custom component (separate repository)
|
||||
- Light, switch, sensor, and number entities per device
|
||||
- Real-time metrics via data coordinator
|
||||
- Real-time metrics via a data coordinator
|
||||
- WebSocket-based live LED preview in HA
|
||||
|
||||
## Platforms
|
||||
|
||||
LedGrab runs as a desktop / server application:
|
||||
|
||||
| Platform | Status | Notes |
|
||||
| -------- | ------ | ----- |
|
||||
| Windows | ✅ Supported | Installer (`.exe`) and portable ZIP; all capture/audio backends |
|
||||
| Linux | ✅ Supported | Tarball and Docker image; X11 capture (Wayland in-container capture not supported) |
|
||||
| macOS | ✅ Supported | Runs from source / Docker; MSS capture |
|
||||
| Docker | ✅ Supported | Multi-arch container image |
|
||||
| Android (TV) | ⚠️ Experimental | An on-device Android-TV build exists (APK attached to releases) but is emulator-verified only and **not officially supported** |
|
||||
|
||||
> **There is no production Android app.** Android phones are only supported as a *capture source* (via scrcpy/ADB) from a desktop host. The on-device Android-TV build is experimental.
|
||||
|
||||
### Feature support by OS
|
||||
|
||||
| Feature | Windows | Linux / macOS | Android TV (experimental) |
|
||||
| ------- | ------- | ------------- | ------------------------- |
|
||||
| Screen capture | DXCam, BetterCam, WGC, MSS | MSS | MediaProjection; root `screenrecord` (rooted devices) |
|
||||
| Webcam capture | OpenCV (DirectShow) | OpenCV (V4L2) | Camera2 (on-demand, while capture is running) |
|
||||
| Audio capture | WASAPI, Sounddevice | Sounddevice (PulseAudio/PipeWire) | AudioPlaybackCapture (API 29+) |
|
||||
| GPU monitoring | NVIDIA (nvidia-ml-py) | NVIDIA (nvidia-ml-py) | — (CPU/RAM/battery/thermal via `/proc`) |
|
||||
| Capture from Android phone | scrcpy (ADB) | scrcpy (ADB) | — (captures its own screen instead) |
|
||||
| Notification capture | WinRT | dbus (Linux) | NotificationListenerService |
|
||||
| Monitor names | Friendly names (WMI) | Generic ("Display 0") | Single built-in display |
|
||||
| LED transports | Network, USB-serial, BLE | Network, USB-serial, BLE | Network, USB-serial (Android driver), BLE (Android bridge) |
|
||||
| Automation: window/process conditions | Supported | Partial | Foreground-app condition (UsageStatsManager) |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.11+ (or Docker)
|
||||
- A supported LED device on the local network or connected via USB
|
||||
- Windows, Linux, or macOS — all core features work cross-platform
|
||||
|
||||
### Platform Notes
|
||||
|
||||
| Feature | Windows | Linux / macOS |
|
||||
| ------- | ------- | ------------- |
|
||||
| Screen capture | DXCam, BetterCam, WGC, MSS | MSS |
|
||||
| Webcam capture | OpenCV (DirectShow) | OpenCV (V4L2) |
|
||||
| Audio capture | WASAPI, Sounddevice | Sounddevice (PulseAudio/PipeWire) |
|
||||
| GPU monitoring | NVIDIA (pynvml) | NVIDIA (pynvml) |
|
||||
| Android capture | Scrcpy (ADB) | Scrcpy (ADB) |
|
||||
| Monitor names | Friendly names (WMI) | Generic ("Display 0") |
|
||||
| Profile conditions | Process/window detection | Not yet implemented |
|
||||
- A supported LED device on the local network, connected via USB/serial, or reachable over Bluetooth
|
||||
- Windows, Linux, or macOS
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker (recommended)
|
||||
### Prebuilt downloads
|
||||
|
||||
Grab a ready-to-run build from the [Releases page](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/releases):
|
||||
|
||||
- **Windows** — `LedGrab-<version>-setup.exe` (installer, no admin required) or `LedGrab-<version>-win-x64.zip` (portable)
|
||||
- **Linux** — `LedGrab-<version>-linux-x64.tar.gz`
|
||||
- **Docker** — see below
|
||||
- **Android TV** — `.apk` (experimental, see [Platforms](#platforms))
|
||||
|
||||
### Docker (recommended for servers)
|
||||
|
||||
```bash
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
|
||||
@@ -115,11 +178,11 @@ export PYTHONPATH=$(pwd)/src # Linux/Mac
|
||||
uvicorn ledgrab.main:app --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
Open **http://localhost:8080** to access the dashboard.
|
||||
Open <http://localhost:8080> to access the dashboard.
|
||||
|
||||
> **Important:** The default API key is `development-key-change-in-production`. Change it before exposing the server outside localhost. See [INSTALLATION.md](INSTALLATION.md) for details.
|
||||
> **Network access:** By default, LedGrab allows anonymous access only from `localhost`. Any request from another machine on your LAN is rejected unless you configure an API key (`auth.api_keys`). Set a key before exposing the server on your network — see [INSTALLATION.md](INSTALLATION.md).
|
||||
|
||||
See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including configuration, Docker manual builds, and Home Assistant setup.
|
||||
See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including configuration, Docker manual builds, and CORS setup.
|
||||
|
||||
## Demo Mode
|
||||
|
||||
@@ -133,50 +196,9 @@ docker compose run -e LEDGRAB_DEMO=true server
|
||||
|
||||
# Python
|
||||
LEDGRAB_DEMO=true uvicorn ledgrab.main:app --host 0.0.0.0 --port 8081
|
||||
|
||||
# Windows (installed app)
|
||||
set LEDGRAB_DEMO=true
|
||||
LedGrab.bat
|
||||
```
|
||||
|
||||
Demo mode uses port **8081**, config file `config/demo_config.yaml`, and stores data in `data/demo/` (separate from production data). It can run alongside the main server.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
ledgrab/
|
||||
├── server/ # Python FastAPI backend
|
||||
│ ├── src/ledgrab/
|
||||
│ │ ├── main.py # Application entry point
|
||||
│ │ ├── config.py # YAML + env var configuration
|
||||
│ │ ├── api/
|
||||
│ │ │ ├── routes/ # REST + WebSocket endpoints
|
||||
│ │ │ └── schemas/ # Pydantic request/response models
|
||||
│ │ ├── core/
|
||||
│ │ │ ├── capture/ # Screen capture, calibration, pixel processing
|
||||
│ │ │ ├── capture_engines/ # MSS, DXCam, BetterCam, WGC, Scrcpy, Camera backends
|
||||
│ │ │ ├── devices/ # WLED, Adalight, AmbileD, DDP, OpenRGB clients
|
||||
│ │ │ ├── audio/ # Audio capture engines
|
||||
│ │ │ ├── filters/ # Post-processing filter pipeline
|
||||
│ │ │ ├── processing/ # Stream orchestration and target processors
|
||||
│ │ │ └── profiles/ # Condition-based profile automation
|
||||
│ │ ├── storage/ # JSON-based persistence layer
|
||||
│ │ ├── static/ # Web dashboard (vanilla JS, CSS, HTML)
|
||||
│ │ │ ├── js/core/ # API client, state, i18n, modals, events
|
||||
│ │ │ ├── js/features/ # Feature modules (devices, streams, targets, etc.)
|
||||
│ │ │ ├── css/ # Stylesheets
|
||||
│ │ │ └── locales/ # en.json, ru.json, zh.json
|
||||
│ │ └── utils/ # Logging, monitor detection
|
||||
│ ├── config/ # default_config.yaml
|
||||
│ ├── tests/ # pytest suite
|
||||
│ ├── Dockerfile
|
||||
│ └── docker-compose.yml
|
||||
├── docs/
|
||||
│ ├── API.md # REST API reference
|
||||
│ └── CALIBRATION.md # LED calibration guide
|
||||
├── INSTALLATION.md
|
||||
└── LICENSE # MIT
|
||||
```
|
||||
Demo mode uses port **8081**, config file `config/demo_config.yaml`, and stores data under `data/demo/` (separate from production data). It can run alongside the main server.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -187,14 +209,15 @@ server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
log_level: "INFO"
|
||||
cors_origins:
|
||||
- "http://localhost:8080"
|
||||
|
||||
auth:
|
||||
api_keys:
|
||||
dev: "development-key-change-in-production"
|
||||
|
||||
storage:
|
||||
devices_file: "data/devices.json"
|
||||
templates_file: "data/capture_templates.json"
|
||||
# Empty (default) → loopback-only anonymous access; LAN requests are rejected.
|
||||
# Add a key to enable LAN/remote access (generate one with: openssl rand -hex 32).
|
||||
api_keys: {}
|
||||
# api_keys:
|
||||
# dev: "your-secret-key-here"
|
||||
|
||||
logging:
|
||||
format: "json"
|
||||
@@ -202,25 +225,26 @@ logging:
|
||||
max_size_mb: 100
|
||||
```
|
||||
|
||||
Environment variable override example: `LEDGRAB_SERVER__PORT=9090`.
|
||||
- Application data is stored in a SQLite database (`data/ledgrab.db` by default). Set `LEDGRAB_DATA_DIR` to relocate the data root (database + assets).
|
||||
- Environment variable override example: `LEDGRAB_SERVER__PORT=9090`.
|
||||
|
||||
See [INSTALLATION.md](INSTALLATION.md) and [`server/.env.example`](server/.env.example) for the full configuration reference.
|
||||
|
||||
## API
|
||||
|
||||
The server exposes a REST API (with Swagger docs at `/docs`) covering:
|
||||
The server exposes a REST API (with interactive Swagger docs at `/docs`) plus WebSocket endpoints. Resources include:
|
||||
|
||||
- **Devices** — CRUD, discovery, validation, state, metrics
|
||||
- **Capture Templates** — Screen capture configurations
|
||||
- **Picture Sources** — Screen capture stream definitions
|
||||
- **Picture Targets** — LED target management, start/stop processing
|
||||
- **Post-Processing Templates** — Filter pipeline configurations
|
||||
- **Color Strip Sources** — Audio, pattern, composite, mapped sources
|
||||
- **Audio Sources** — Multichannel and mono audio device configuration
|
||||
- **Pattern Templates** — Effect pattern definitions
|
||||
- **Value Sources** — Dynamic brightness/value providers
|
||||
- **Key Colors Targets** — KC targets with WebSocket live color stream
|
||||
- **Profiles** — Condition-based automation profiles
|
||||
- **Capture Templates** & **Picture Sources** — screen capture configuration and stream definitions
|
||||
- **Output Targets** — LED target management, start/stop processing, live color stream
|
||||
- **Post-Processing Templates** — filter pipeline configurations
|
||||
- **Color Strip Sources**, **Pattern Templates**, **Gradients** — color generation
|
||||
- **Audio Sources / Templates / Filters** — audio capture and reactive processing
|
||||
- **Value Sources**, **Weather Sources**, **Scene Presets** — dynamic parameters and presets
|
||||
- **Automations**, **Webhooks**, **HTTP Endpoints**, **Game Integration** — triggers and rules
|
||||
- **MQTT** & **Home Assistant** — broker sources and HA integration
|
||||
|
||||
All endpoints require API key authentication via `X-API-Key` header or `?token=` query parameter.
|
||||
Authentication uses a Bearer token (`Authorization: Bearer <api-key>`) when API keys are configured; loopback requests are anonymous by default. WebSocket connections authenticate via a first-message handshake.
|
||||
|
||||
See [docs/API.md](docs/API.md) for the full reference.
|
||||
|
||||
@@ -253,16 +277,16 @@ ruff check src/ tests/
|
||||
Optional extras:
|
||||
|
||||
```bash
|
||||
pip install -e ".[perf]" # High-performance capture engines (Windows)
|
||||
pip install -e ".[camera]" # Webcam capture via OpenCV
|
||||
pip install -e ".[perf]" # High-performance capture engines (Windows: DXCam, BetterCam, WGC)
|
||||
pip install -e ".[notifications]" # OS notification capture (WinRT / dbus)
|
||||
pip install -e ".[scrcpy]" # Capture from an Android phone via scrcpy
|
||||
pip install -e ".[ble]" # Bluetooth LE LED controllers (desktop only)
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. LedGrab is MIT-licensed, so you're free to fork, modify, and self-host. Please open an issue or pull request on the [repository](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [WLED](https://github.com/Aircoookie/WLED) — LED control firmware
|
||||
- [FastAPI](https://fastapi.tiangolo.com/) — Python web framework
|
||||
- [MSS](https://python-mss.readthedocs.io/) — Cross-platform screen capture
|
||||
MIT — see [LICENSE](LICENSE). Free and open source.
|
||||
|
||||
+71
-11
@@ -1,20 +1,66 @@
|
||||
## v0.4.2 (2026-04-22)
|
||||
## v0.9.0 (2026-06-23)
|
||||
|
||||
### Bug Fixes
|
||||
- Ship previously-missing package assets in release artifacts — prebuilt notification sounds (`alert`, `bell`, `chime`, `ping`, `pop`) and game adapter YAMLs (`minecraft`, `rocket_league`, `valorant`). An unanchored `data/` rule in `.gitignore` was matching `server/src/ledgrab/data/`, so these files never reached the tag or CI builds. Also bump the `_FALLBACK_VERSION` literal to `0.4.2` so the Windows installer (which strips `.dist-info`) reports the correct version in the WebUI instead of `0.3.0`. Build scripts now patch this literal automatically to prevent future drift. ([5db6edd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5db6edd))
|
||||
A large feature release: a full activity/audit log, two roadmap batches of
|
||||
capture and smart-light improvements, per-pixel control for LIFX/Hue/Nanoleaf,
|
||||
and new outbound integrations (webhooks + Home Assistant MQTT discovery).
|
||||
|
||||
### Features
|
||||
- Restyle the enhanced header locale picker as a LED-accent badge — 2-letter code in Orbitron, collapses to just the badge on narrow screens ([9ce1dc3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ce1dc3))
|
||||
|
||||
#### Activity Log
|
||||
- Persistent activity/audit log: storage model with migration, recorder with
|
||||
actor context and retention, and event instrumentation across four
|
||||
categories ([1ac4a0f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ac4a0f), [726f39e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/726f39e), [25c613c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/25c613c))
|
||||
- REST API for list / export / settings / clear ([4a09275](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4a09275))
|
||||
- Activity tab with smart filtering, live updates, and export, plus a
|
||||
dashboard widget and settings panel ([9a0137f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9a0137f), [6e1dd21](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e1dd21))
|
||||
|
||||
#### Per-pixel smart lights
|
||||
- LIFX multizone (SetExtendedColorZones) and Tile per-pixel streaming,
|
||||
auto-detected on connect with single-colour fallback ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
|
||||
- Philips Hue gradient-lightstrip mapping: Entertainment v2 frames keyed by
|
||||
channel id, with a `hue_gradient_mode` toggle ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
|
||||
- Nanoleaf extControl v2 per-panel UDP streaming (`per_panel` mode) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||
|
||||
#### Capture & effects
|
||||
- Linear-light blending and spatio-temporal dithering, opt-in per calibration ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||
- Audio-reactive palette modulation across all 12 procedural effects ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||
- Color-harmony gradient generator (complementary / analogous / triadic / …) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||
|
||||
#### Automations & integrations
|
||||
- Solar sunrise/sunset automation trigger (new `utils/solar.py`) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||
- Outbound webhook automation action (Discord / IFTTT / Zapier / Node-RED),
|
||||
SSRF-gated at save and fire time ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
|
||||
- Home Assistant MQTT auto-discovery: read-only binary sensors per automation,
|
||||
availability via birth/will, with cleanup on disable/delete ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
|
||||
- League of Legends poller wired via a `LoLPollManager` + shared runtime state ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||
- `auth.expose_docs` flag (default off) to view `/docs`, `/redoc`, and
|
||||
`/openapi.json` without a token; all real endpoints stay protected ([126d8f2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/126d8f2))
|
||||
|
||||
### Bug Fixes
|
||||
- Pre-release review hardening: solar timezone crash, webhook header CRLF,
|
||||
MQTT topic-prefix injection, thread-safe `get_stats` copy, MQTT discovery
|
||||
lock, `reactive_mode` Literal, and calibration-modal accessibility ([0c096db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0c096db))
|
||||
- Comprehensive review fixes across security, concurrency, performance,
|
||||
Android, and UI ([17dd2e0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17dd2e0))
|
||||
- Activity Log polish: accessible export menu, i18n placeholders, dashboard
|
||||
section reconciliation, column alignment, ticking time, and no spinner
|
||||
flash on instant filtering ([3dd1ac3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3dd1ac3), [ff1ff06](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ff1ff06), [77284e8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/77284e8), [ae74cca](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ae74cca), [077c99c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/077c99c))
|
||||
|
||||
---
|
||||
|
||||
### Development / Internal
|
||||
|
||||
#### CI/Build
|
||||
- Publish `.sha256` sidecars alongside release assets for easier integrity verification ([03d2e6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/03d2e6b))
|
||||
- Best-effort arm64 multi-arch Docker manifest via QEMU + `docker manifest`
|
||||
(amd64 path untouched) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||
|
||||
#### Refactoring
|
||||
- Move the Key Colors test out of the lightbox and into the `test-css-source` modal where the rest of the source-render debug tools live ([be2d5e1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/be2d5e1))
|
||||
#### Chores
|
||||
- Activity Log feature plan/subplan scaffold, post-merge cleanup, and context
|
||||
graduated into CLAUDE.md ([1afe7d6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1afe7d6), [e584235](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e584235))
|
||||
|
||||
> Tests: ~180 new unit tests added across the activity log, roadmap features,
|
||||
> and integrations. Release gate green: ruff + tsc + build clean,
|
||||
> **pytest 2739 passed / 2 skipped**.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,9 +69,23 @@
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------|---------|--------|
|
||||
| [be2d5e1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/be2d5e1) | refactor(color-strips): move Key Colors test from lightbox into test-css-source modal | alexei.dolgolyov |
|
||||
| [5db6edd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5db6edd) | fix(release): ship prebuilt assets and bump fallback version | alexei.dolgolyov |
|
||||
| [9ce1dc3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9ce1dc3) | feat(ui): restyle enhanced header locale picker as LED-accent badge | alexei.dolgolyov |
|
||||
| [03d2e6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/03d2e6b) | ci(release): publish .sha256 sidecars alongside release assets | alexei.dolgolyov |
|
||||
| [0c096db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0c096db) | fix: address pre-release review findings (2026-06-23) | alexei.dolgolyov |
|
||||
| [39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554) | feat: roadmap round two (2026-06-23) — per-pixel smart-lights + integrations | alexei.dolgolyov |
|
||||
| [6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25) | feat: roadmap batch (2026-06-19) — solar/linear-light/dither/nanoleaf + integrations | alexei.dolgolyov |
|
||||
| [126d8f2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/126d8f2) | feat(auth): add auth.expose_docs flag to view API docs without a token | alexei.dolgolyov |
|
||||
| [e584235](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e584235) | chore(activity-log): post-merge cleanup + graduate context to CLAUDE.md | alexei.dolgolyov |
|
||||
| [077c99c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/077c99c) | fix(activity-log): no spinner flash on instant filtering | alexei.dolgolyov |
|
||||
| [ae74cca](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ae74cca) | fix(activity-log): UI polish - accessible export menu, i18n placeholders, zero-result spinner fix | alexei.dolgolyov |
|
||||
| [77284e8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/77284e8) | fix(activity-log): dashboard section reconciliation + activity column alignment | alexei.dolgolyov |
|
||||
| [ff1ff06](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ff1ff06) | fix(activity-log): post-test polish - localize descriptions, dashboard widget, ticking time | alexei.dolgolyov |
|
||||
| [3dd1ac3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3dd1ac3) | fix(activity-log): final-review fixes - crosslink keys + sanitize parity | alexei.dolgolyov |
|
||||
| [6e1dd21](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e1dd21) | feat(activity-log): phase 6 - dashboard widget + settings panel + docs | alexei.dolgolyov |
|
||||
| [9a0137f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9a0137f) | feat(activity-log): phase 5 - Activity tab (smart filtering, live updates, export) | alexei.dolgolyov |
|
||||
| [4a09275](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4a09275) | feat(activity-log): phase 4 - REST API (list/export/settings/clear) | alexei.dolgolyov |
|
||||
| [25c613c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/25c613c) | feat(activity-log): phase 3 - event instrumentation (4 categories) | alexei.dolgolyov |
|
||||
| [726f39e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/726f39e) | feat(activity-log): phase 2 - recorder, actor context, retention, lifecycle | alexei.dolgolyov |
|
||||
| [1ac4a0f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ac4a0f) | feat(activity-log): phase 1 - storage model, migration, repository | alexei.dolgolyov |
|
||||
| [1afe7d6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1afe7d6) | chore(activity-log): scaffold feature plan and phase subplans | alexei.dolgolyov |
|
||||
| [17dd2e0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17dd2e0) | fix: resolve comprehensive review findings (security, concurrency, perf, Android, UI) | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
# Dashboard Reconciliation — Review Notes
|
||||
|
||||
*Captured 2026-05-26. Session focused on dashboard + perf-card flicker and per-poll re-rendering.*
|
||||
|
||||
*Updated 2026-05-27 — widened the audit beyond the two poll timers and found a **second driver** (server push) plus the **highest-blast-radius site** (`entity-events.ts`). Added §3.5, corrected the "out of scope" reasoning in §5, and confirmed the decision: **commit to the Lit migration**. Implementation deferred — this is still a planning doc, not a spec.*
|
||||
|
||||
This is a thinking-aloud document for whoever picks up reconciliation work next (likely me). It captures the bug class, what's already shipped, what's still latent, the decision ladder we walked through, and the recommendation we landed on. It is **not** a spec — treat any code shown as illustrative.
|
||||
|
||||
---
|
||||
|
||||
## 1. The bug class in one sentence
|
||||
|
||||
> Every place a data-driven render — a poll timer **or** a server-pushed `server:*` event — writes `el.innerHTML = ...`, the existing DOM is torn down — even when the new HTML equals the old — which restarts CSS animations, drops focus, skips transitions, and burns wasted DOM mutation cycles.
|
||||
|
||||
The symptom only becomes visually loud when the destroyed subtree contains a CSS keyframe animation (e.g. the pulsing `.perf-patches-empty-dot`). Everywhere else the cost is silent: lost transitions, broken focus, wasted layout work. The bug is **load-bearing in the architecture**, not in any single call site — that's why we keep coming back to it.
|
||||
|
||||
---
|
||||
|
||||
## 2. What landed in commit `f6486f9` (this session)
|
||||
|
||||
Tactical work — solves the worst cases, does not change the architecture.
|
||||
|
||||
### `server/src/ledgrab/static/js/features/dashboard.ts`
|
||||
- Collapsed the two fast-path branches into one. Fast path runs when `structureUnchanged && !forceFullRender` regardless of `running.length`. Previously, **zero running targets meant every poll rebuilt the entire dashboard** even when nothing changed.
|
||||
- `_lastSyncClockIds` no longer fingerprints `is_running` — pausing/resuming a clock no longer tears down every card. `_updateSyncClocksInPlace` already handles the toggle.
|
||||
- `_updateAutomationsInPlace` now called from the unified fast path. Automation badges were silently going stale on the fast path.
|
||||
- `_initFpsCharts` rewritten diff-based: only destroy charts for ids that left or whose canvas was detached by a DOM swap; only create for new ids; only fetch `/api/metrics/history` when there are genuinely new ids needing seed data.
|
||||
- Sync-clock pause/resume/reset callers + `server:automation_state_changed` SSE handler now use `loadDashboard()` (no force) — `forceFullRender` is now actually load-bearing, meaning "settings changed, full rebuild required."
|
||||
|
||||
### `server/src/ledgrab/static/js/features/perf-charts.ts`
|
||||
- `_renderChartSvg` no longer rewrites `innerHTML` per poll. The SVG skeleton (ref line + sys area/line + app line) is built once via `_ensureSparkNodes` and mutated thereafter. WeakMap cache (`_sparkNodeCache`) keyed by host element avoids the per-tick `querySelector` cost.
|
||||
- Hidden cards (env-disabled GPU/Temp) skip render entirely.
|
||||
- `_fetchPerformance` switched to `fetchWithAuth`.
|
||||
- Hardcoded English strings replaced with `t()` calls. New keys: `perf.no_captures`, `perf.captures_count.{one,few,many,other}`, `perf.ratio_of_requested`, `perf.total_count`, `perf.skipped_per_sec`, `perf.tip.now`, `perf.tip.ago` (en/ru/zh).
|
||||
- Tooltip reads `dashboardPollInterval` per mousemove tick (was captured at bind time).
|
||||
- Dead `<defs><linearGradient>` block removed.
|
||||
- `updateTotalCaptureFpsActual` now delegates to `_paintCaptureFpsActualValue` — single code path.
|
||||
- `updateActivePatches` / `updateDevices` skip the `innerHTML` write when content signature hasn't changed. This is the direct fix for the "READY TO LAUNCH flickers every update" report — the empty-state dot's CSS pulse no longer resets.
|
||||
- Two missing semicolons in `_seedAggregateHistories` (ASI was saving us).
|
||||
|
||||
### Reviewer findings addressed (typescript-reviewer pass)
|
||||
- **HIGH:** `_metricLabel` was looking up `dashboard.perf.${key}` but the FPS family uses `dashboard.perf.total_fps`, `total_capture_fps`, `total_capture_fps_actual`. Tooltip would have shouted `FPS` / `CAPTURE_FPS` / `CAPTURE_FPS_ACTUAL`. Fixed via explicit `METRIC_LABEL_KEYS` map.
|
||||
- **HIGH:** `_ensureSparkNodes` silently coerced `null` children to non-null when the SVG existed but a child was missing. Hardened to validate all four children and rebuild if any are missing.
|
||||
|
||||
---
|
||||
|
||||
## 3. Hot spots still latent
|
||||
|
||||
These are the call sites where `innerHTML` is still written every poll. None are flickering today (no CSS animations on their inner elements), but every one is the same bug shape and will bite the next time someone adds a keyframe / transition / focus target inside.
|
||||
|
||||
### `perf-charts.ts`
|
||||
|
||||
| Line | Site | Fires per poll? | Notes |
|
||||
|------|------|-----------------|-------|
|
||||
| 462 | `updateActivePatches` → `listEl.innerHTML` | yes | guarded by signature compare (✓) |
|
||||
| 493 | `updateTotalFps` → `valEl.innerHTML` | yes | FPS value, no inner animation |
|
||||
| 526 | `updateTotalCaptureFps` → `valEl.innerHTML` | yes | same |
|
||||
| 638 | `_paintNetworkValue` → `valEl.innerHTML` | yes | bytes/s value |
|
||||
| 655 | `_paintDeviceLatencyValue` → `valEl.innerHTML` (no-devices hint) | yes | hint span |
|
||||
| 657 | `_paintDeviceLatencyValue` → `valEl.innerHTML` (offline hint) | yes | hint span |
|
||||
| 660 | `_paintDeviceLatencyValue` → `valEl.innerHTML` (ms value) | yes | value |
|
||||
| 676 | `_paintSendTimingValue` → `valEl.innerHTML` (idle hint) | yes | hint span |
|
||||
| 679 | `_paintSendTimingValue` → `valEl.innerHTML` (ms value) | yes | value |
|
||||
| 738 | `_paintErrorsValue` → `valEl.innerHTML` | yes | rate value |
|
||||
| 806 | `updateDevices` → `dotsEl.innerHTML` | yes | guarded by signature compare (✓) |
|
||||
| 1086 | `_renderValuePair` → `mainEl.innerHTML = appVal` | yes | dual sys/app value |
|
||||
| 1088 | `_renderValuePair` → `mainEl.innerHTML = sysVal` | yes | dual sys/app value |
|
||||
| 1094 | `_renderValuePair` → `tagEl.innerHTML` (App tag) | mode='both' only | App tag in `both` mode |
|
||||
| 1181 | `_applyPerfDataToDom` temp hint | only when cpu_temp_hint_key changes | rare |
|
||||
| 1449 | `_paintFpsValue` | seed only | once per init |
|
||||
| 1456 | `_paintCaptureFpsValue` | seed only | once per init |
|
||||
| 1463 | `_paintCaptureFpsActualValue` (no-captures hint) | yes via live updater | now goes through painter |
|
||||
| 1469 | `_paintCaptureFpsActualValue` (value) | yes via live updater | same |
|
||||
| 1499 | `_paintErrorsValue` (duplicate of 738) | seed only | once per init |
|
||||
| 1823 | tooltip `tip.innerHTML` | per mousemove | rate-limited by hover only |
|
||||
|
||||
### `dashboard.ts`
|
||||
|
||||
| Line | Site | Fires per poll? | Notes |
|
||||
|------|------|-----------------|-------|
|
||||
| 275 | `_updateRunningMetrics` → `fpsEl.innerHTML` | per running target | live FPS pill — visible churn |
|
||||
| 293 | `_updateRunningMetrics` → `labelEl.innerHTML` (errors label) | per running target | rebuilt each poll |
|
||||
| 340 | `_updateAutomationsInPlace` → `btn.innerHTML` | only on enable/disable change | low frequency |
|
||||
| 366 | `_updateSyncClocksInPlace` → `btn.innerHTML` | per poll for every clock | wasteful |
|
||||
| 975 | `loadDashboard` first-load → `container.innerHTML` | once per init | fine |
|
||||
| 989 | `loadDashboard` slow path → `dynamic.innerHTML = dynamicHtml` | only when slow path fires | the **big** swap, scoped already |
|
||||
| 1010 | `loadDashboard` error path | rare | fine |
|
||||
| 1416 | `subscribeDashboardLayout` clear | rare | fine |
|
||||
|
||||
### What this list tells us
|
||||
|
||||
- The remaining innerHTML writes are **per-cell value updates** that paint formatted spans (`{value}<span class="perf-fps-unit">fps</span>`). Each rewrite destroys two text nodes + a span every poll across ~10 cells. Not flickering today; will flicker the moment anyone adds an animation to `.perf-fps-unit` or `.perf-fps-ceiling`.
|
||||
- The pattern can be killed without architectural change by splitting these into a stable structure (number text node + static unit span) and only updating `textContent` of the number. That's what L3 / Lit would force naturally.
|
||||
|
||||
---
|
||||
|
||||
## 3.5 Beyond dashboard/perf — push-driven reconciliation
|
||||
|
||||
*Added 2026-05-27. The §3 audit was scoped to the two poll timers we were debugging. Widening the `\.innerHTML\s*=` search showed the bug class has a **second driver** and lives outside dashboard/perf too.*
|
||||
|
||||
### Two drivers, not one
|
||||
|
||||
The teardown is triggered by anything that re-renders **without user intent**:
|
||||
|
||||
- **Poll timers** (`setInterval`) — what §2/§3 covered (`dashboard.ts` `_uptimeTimer` + main refresh, `perf-charts.ts` `_pollTimer`).
|
||||
- **Server-pushed `server:*` events** — `core/events-ws.ts` turns each WS message into a `server:*` CustomEvent; feature modules listen and re-render through the *same* `innerHTML` paths.
|
||||
|
||||
So the one-line bug class in §1 reads "poll- **or** push-driven," not just poll.
|
||||
|
||||
### Genuinely-affected sites outside dashboard/perf
|
||||
|
||||
| Site | Driver | Shape | Notes |
|
||||
| ---- | ------ | ----- | ----- |
|
||||
| `core/entity-events.ts` `_invalidateAndReload` | push (`server:entity_changed`, `server:device_health_changed`) | full-**tab** rebuild via `loadTargetsTab` / `loadPictureSources` / `loadAutomations` / `loadIntegrations` | **highest blast radius.** A single pushed entity change tears down and rebuilds an entire tab — losing scroll, focus, open inline editors, restarting card-enter animations. |
|
||||
| `features/game-integration.ts` event feed (`_eventMonitorTimer`) | poll (2 s) | `feed.innerHTML = events.slice(0,20).map(...)` | full 20-item list rebuild every 2 s while the panel is open. |
|
||||
| `features/game-integration.ts` connection test (`_connectionTestTimer`) | poll | `panel.innerHTML = …` per tick | transient, low frequency. |
|
||||
|
||||
`entity-events.ts` already has the **L1 floor applied by hand**: a 600 ms debounce plus a diff check (`oldData === newData`, then length + `id` + `updated_at` compare) that skips the reload when nothing changed. That kills the *no-op* case — but a **real** change still does the full-tab teardown. This is exactly the §4-L1 limitation ("still tears down when content *does* differ"), live across the whole app.
|
||||
|
||||
### Counter-examples that already do it right
|
||||
|
||||
Two poll loops never flicker because they mutate `textContent` on a **stable structure** instead of rewriting `innerHTML`:
|
||||
|
||||
- `core/api.ts` `loadServerInfo` (connection-check poll) — `versionEl.textContent` / `statusEl.textContent`.
|
||||
- `features/color-strips/test.ts` FPS sampler (1 s) — `valueEl.textContent` / `avgEl.textContent`.
|
||||
|
||||
These are live proof that "stable structure + mutate text node" is the fix — i.e. what L3 / Lit force by construction.
|
||||
|
||||
### What this changes about the plan
|
||||
|
||||
The §4 ladder was reasoned entirely around **per-cell** rendering, because that was the visible flicker. The push-driven finding surfaces a second, qualitatively different problem:
|
||||
|
||||
- **Problem A — cell value churn:** every poll, one value span. Loud only with animations. *Mostly fixed in `f6486f9`.* → wants `setText` / skip-if-unchanged.
|
||||
- **Problem B — list/tab teardown:** on change/push, an entire list or tab. Loses scroll/focus/open editors. *Unaddressed.* `entity-events.ts` and the game feed are Problem B. → wants **keyed list reconciliation**.
|
||||
|
||||
Problem B is a **list-level** concern, not a cell-level one. In Lit terms it maps to a keyed `repeat()` directive over the tab/list body — the dashboard-card work in Phase 2 already needs this, but `entity-events.ts` needs it for tabs that §5 used to list as "out of scope." This does **not** change the chosen direction (Lit); it adds `entity-events.ts` as a first-class, high-priority target.
|
||||
|
||||
---
|
||||
|
||||
## 4. Decision ladder
|
||||
|
||||
Walked through with the user 2026-05-26. Captured here so we don't re-litigate.
|
||||
|
||||
### L1 — drop-in `setInnerHtmlIfChanged` helper
|
||||
- **Shape:** `WeakMap<Element, string>` cache; replace every `el.innerHTML = x` with `setInnerHtmlIfChanged(el, x)`.
|
||||
- **Wins:** stops the no-change rewrites globally; zero behavior risk; ~30 call-site changes.
|
||||
- **Misses:** still tears down DOM when content *does* differ (e.g. FPS row values change every tick); doesn't preserve focus/transition state inside a list.
|
||||
- **Verdict:** floor, not ceiling. Worth doing for cells that don't get migrated to L3/Lit.
|
||||
|
||||
### L2 — lint guard
|
||||
- **Shape:** pre-commit script greps `\.innerHTML\s*=` in `static/js/` outside an allowlist, fails the commit.
|
||||
- **Wins:** keeps the discipline; cheap.
|
||||
- **Misses:** only useful as a pair with L1+; bare guard with no helper makes contributors angry.
|
||||
- **Verdict:** pair with whatever helper we land on.
|
||||
|
||||
### L3 — hand-rolled cell-component pattern
|
||||
- **Shape:** `defineCell({ html, refs, mount, update, unmount })` + `reconcileList(host, items, binding)` + `setText/setClass/setAttr` mutators. ~150–300 lines of runtime.
|
||||
- **Wins:** correct by construction; no dependencies; explicit about what mutates; composes with existing customize panel / color picker.
|
||||
- **Misses:** we own the abstraction — it grows over time as we need transitions, async data, focus, devtools, error boundaries. Death by a thousand features.
|
||||
- **Verdict:** second-best. Strong contender if zero-deps is a hard constraint.
|
||||
|
||||
### Lit migration of polling modules — **recommended**
|
||||
- **Shape:** convert each perf cell + each dashboard card cell to a Lit web component. Use `html\`<span>${value}</span>\`` tagged-template + targeted diff. ~5KB gzip added to bundle, no new build step (esbuild handles it).
|
||||
- **Wins:** solves the bug class by design; maintained by Google + community; web-components-based so no framework lock-in; composes with vanilla DOM trivially; mental model is close to current template-string idiom; non-polling code can stay vanilla forever.
|
||||
- **Misses:** introduces a dependency; contributors learn one more thing; rare edge cases (`@html`-equivalent exists and reintroduces the bug if misused).
|
||||
- **Verdict:** best ceiling-to-cost ratio for a small team. Recommended.
|
||||
|
||||
### Full framework rewrite (React / Vue / Solid)
|
||||
- **Verdict:** overkill. The bug class lives in polling paths; the rest of the app is fine. Spending the migration budget on rebuilding IconSelect / EntitySelect / modals / customize panel / graph editor — none of which are broken — is a bad trade.
|
||||
|
||||
---
|
||||
|
||||
## 5. Recommendation
|
||||
|
||||
**Lit for the polling-heavy modules.**
|
||||
|
||||
Migration plan:
|
||||
|
||||
### Phase 0 — spike (2-hour time-box)
|
||||
- Convert `patches` cell to a Lit component, end to end.
|
||||
- Verify it plays nicely with: color picker integration, customize panel layout reorder, `rerenderPerfGrid` reconciliation, `setPerfMode` toggle, hidden-by-env state, the spark tooltip handler.
|
||||
- If any of those break in an unfixable way → pivot to L3.
|
||||
- If they work → commit to the migration.
|
||||
|
||||
### Phase 1 — perf-charts cells
|
||||
1. `patches` (already spiked)
|
||||
2. `devices`
|
||||
3. `fps` / `capture_fps` / `capture_fps_actual` (share a sparkline base class)
|
||||
4. `cpu` / `ram` / `gpu` / `temp` (share `_sparkCardHtml` template family)
|
||||
5. `network` / `device_latency` / `send_timing` / `errors`
|
||||
|
||||
Each is its own PR, dashboard stays working at every step. `renderPerfSection` becomes a registry of Lit components; `rerenderPerfGrid` becomes "reorder existing elements in the grid" (which it mostly already does).
|
||||
|
||||
### Phase 2 — dashboard card cells
|
||||
6. Output target cards (running variant — biggest payoff, has live FPS + uptime + errors)
|
||||
7. Output target cards (stopped variant)
|
||||
8. Sync clock cards
|
||||
9. Automation cards
|
||||
10. Integration (HA / MQTT) cards
|
||||
|
||||
These get bigger wins from the migration because they have nested mutable state (FPS pill, errors cell, health dot, action button) that's currently rebuilt per poll via the `_updateRunningMetrics` path.
|
||||
|
||||
### Highest-impact: `entity-events.ts` tab reconciliation (sequence early)
|
||||
|
||||
`entity-events.ts` (§3.5) is the single highest-blast-radius site and is **not** on the dashboard — it re-renders the Targets / Integrations / Automations tabs on server push. Whether or not those tabs' cells become Lit components, the loader path (`loadTargetsTab` / `loadIntegrations` / `loadAutomations`) should switch from a full `innerHTML` rebuild to a **keyed list reconcile** (a Lit `repeat()` over the tab body). This preserves scroll / focus / open inline editors across pushes. If the goal is "biggest UX win first" rather than "lowest-risk first," sequence this ahead of Phase 2.
|
||||
|
||||
### Phase 3 — stopgap helper for the rest
|
||||
Add `setInnerHtmlIfChanged` and apply to any remaining vanilla polling sites we don't plan to migrate. Add the L2 lint guard at this point — by now everything that polls is either Lit-managed or uses the helper.
|
||||
|
||||
### Out of scope (deliberately) — with one correction (2026-05-27)
|
||||
|
||||
- Targets tab, automations editor, integrations, scene presets — these render on-demand, **but they are ALSO re-rendered on server push** via `entity-events.ts` (see §3.5). The original claim that "the bug class doesn't bite them" was **wrong**: a pushed `server:entity_changed` does a full-tab `innerHTML` teardown. The *editor / on-demand views* can stay vanilla, but the **list/tab render that entity-events triggers needs reconciliation** (a keyed list diff) regardless of whether those cells become Lit components. Treat the entity-events reload path as **in-scope** — it is the highest-blast-radius Problem B site.
|
||||
- Color strips editor, graph editor, settings — genuinely on-demand, no push re-render path, stay vanilla.
|
||||
- Transport bar cells (CPU/Mem chip in the top bar) — read from the same perf payload, can be migrated opportunistically but not urgent.
|
||||
|
||||
---
|
||||
|
||||
## 6. Open questions to settle before committing
|
||||
|
||||
These came up during the discussion and weren't resolved:
|
||||
|
||||
1. **Bundle-size budget.** Is +5KB acceptable? Current bundle is 2.7MB so this is noise — but worth confirming there isn't a strict cap (e.g. for slow networks / Android Chaquopy embed).
|
||||
2. **Contributor model.** If the project will grow to multiple contributors, Lit's smaller community vs React's is a recruiting tradeoff. Currently solo-ish, so probably moot.
|
||||
3. **Android TV target.** Chaquopy embed serves the same bundle. Lit works fine in any modern browser — Android TV WebView is Chromium-based. Should be a no-op but verify in Phase 0 spike.
|
||||
4. **Long-term framework intent.** If there's a chance we ever migrate to React/Vue/Solid for the rest of the app, doing Lit now is *not* lock-in (web components are standard), but it does add a second mental model. Probably fine; just naming the tradeoff.
|
||||
5. **Customize panel.** The drag-reorder code in `dashboard-customize.ts` mutates `.dashboard-section` DOM directly. Lit components reorder cleanly via `moveBefore` / `insertBefore` since they're just elements, but the dnd library needs to treat them as opaque drag handles. Phase 0 spike should confirm.
|
||||
|
||||
---
|
||||
|
||||
## 7. Pointers
|
||||
|
||||
- Source files most relevant:
|
||||
- `server/src/ledgrab/static/js/features/dashboard.ts`
|
||||
- `server/src/ledgrab/static/js/features/perf-charts.ts`
|
||||
- `server/src/ledgrab/static/js/features/dashboard-layout.ts` (cell ordering + visibility)
|
||||
- `server/src/ledgrab/static/js/features/dashboard-customize.ts` (drag-reorder UI)
|
||||
- `server/src/ledgrab/static/js/core/card-modes.ts` (mode toggle that hangs off section headers)
|
||||
- `server/src/ledgrab/static/js/core/entity-events.ts` (push-driven tab reloads — §3.5, highest blast radius)
|
||||
- `server/src/ledgrab/static/js/core/events-ws.ts` (WS → `server:*` CustomEvent dispatch)
|
||||
- `server/src/ledgrab/static/js/features/game-integration.ts` (2 s event-feed list rebuild — §3.5)
|
||||
- Most recent reconciliation commit: `f6486f9`.
|
||||
- Related skill files in `~/.claude/skills/`: `frontend-patterns`, `documentation-lookup` (for Lit docs via Context7).
|
||||
- Locale convention: `perf.*` for cross-card primitives, `dashboard.perf.*` for cell titles.
|
||||
|
||||
---
|
||||
|
||||
## 8. If this doc gets stale
|
||||
|
||||
If you read this and the perf cells are already Lit components — delete this file. If you read this and there's a new flicker / focus / transition bug nobody can explain — search for `\.innerHTML\s*=` in `static/js/features/` **and `static/js/core/`** (`entity-events` lives in core) and you've probably found it. For *state loss on a server event* (scroll jump, focus drop, an inline editor closing itself), look at the `server:*` listeners in `core/entity-events.ts` first.
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
# Production Review — Remaining Items
|
||||
|
||||
Output of the multi-agent production review (security / Python / TypeScript /
|
||||
performance / architecture / code-quality). Each entry below is something
|
||||
the original audit flagged and the autonomous hardening pass deliberately
|
||||
did **not** address — either because it needs design input, profiling
|
||||
validation, or a multi-day refactor that should land in its own session.
|
||||
|
||||
The hardening pass landed everything else: see git log between `master` and
|
||||
the head of the review branch for the applied changes (URL-scheme +
|
||||
malicious-input rejection, IconSelect XSS escape, MiniSelect for forbidden
|
||||
plain `<select>`s, WebSocket Origin allow-list, /docs auth-gate, security
|
||||
headers middleware, streaming upload size caps, fire-and-forget task
|
||||
tracking + drain resilience in MQTT runtime, discovery_watcher task
|
||||
tracking, asyncio.gather return_exceptions, secret_box encryption for MQTT
|
||||
/ Hue / Govee credentials with auto-migration, SSRF-validated update
|
||||
redirects, single source of truth for IP classification in
|
||||
`utils/net_classify.py`, allowlist + parity test for inbound WS events,
|
||||
typed `Window` globals, and more).
|
||||
|
||||
## Items completed in the follow-up autonomous pass (2026-05-23)
|
||||
|
||||
- [x] **devices.py PATCH-without-url processor desync** — `update_device`
|
||||
now falls back to `existing.url` so a rename / icon-only edit
|
||||
always tells the processor the current address.
|
||||
- [x] **WLED scheme integration test** on `/api/v1/devices` — covers
|
||||
bare IPv4 (`http://`), public hostname (`https://`), and trailing-slash
|
||||
normalisation; lives in `tests/api/routes/test_devices_routes.py`.
|
||||
- [x] **IPv6 regression test** — `tests/test_url_scheme.py` now pins
|
||||
public IPv6 → `https://`, ULA → `http://`, and documents the
|
||||
Python-`ipaddress` documentation-prefix classification quirk.
|
||||
- [x] **IconSelect XSS audit + defence-in-depth** — every caller
|
||||
audited (all feed `icon` from constants or lookup tables); added
|
||||
`sanitiseIcon` that rejects `<script>`, `javascript:`, `on*=`,
|
||||
`<iframe>`, `<embed>`, `<object>` and warns to the console.
|
||||
- [x] **`Optional[T]` → `T | None` (PEP 604)** — 55 sites cleaned via
|
||||
`ruff --fix UP007`. The remaining `Union[…]` aliases for
|
||||
pixel/colour/device-config typing converted by hand. `UP007` now
|
||||
lives in `pyproject.toml` so the rule fires on new code.
|
||||
- [x] **Hot-path magic numbers → named constants** — `processed_stream`
|
||||
gains `_FILTER_RECHECK_EVERY_N_FRAMES`; `wled_target_processor`
|
||||
gains `_SKIP_REPOLL_SLEEP_SECONDS`, `_DIAGNOSTICS_REPORT_INTERVAL_SECONDS`,
|
||||
`_CSPT_RECHECK_EVERY_N_ITERATIONS`.
|
||||
- [x] **`api/auth.py` `except Exception` tightening** — every WS send /
|
||||
close site is now `except _WS_SEND_BENIGN_EXC` (a narrow tuple of
|
||||
WebSocketDisconnect / RuntimeError / ConnectionError / OSError).
|
||||
The auth-receive path catches the same set plus a final
|
||||
`logger.exception` catch-all for observability on truly unexpected
|
||||
shapes.
|
||||
- [x] **`(window as any)` cleanup** — 59 static-property accesses
|
||||
migrated to typed `window.<name>` against `global-types.d.ts`. The
|
||||
remaining 7 sites use dynamic string indexing (`window[fnName]`)
|
||||
and intentionally keep the cast (documented in the typedef file).
|
||||
|
||||
---
|
||||
|
||||
## Architecture refactors (multi-day — own session)
|
||||
|
||||
- [ ] **Split `core/processing/value_stream.py`** (1856 LOC, 14 stream classes)
|
||||
into a `value_streams/` package. Each value-stream type gets its own
|
||||
file ≤300 LOC; `manager.py` holds `ValueStreamManager`.
|
||||
- [ ] **Split `storage/color_strip_source.py`** (1841 LOC, 18 source kinds)
|
||||
into a `color_strip_sources/` package mirroring `value_streams/`.
|
||||
- [ ] **Frontend file splits** — `graph-editor.ts` (2707), `streams.ts`
|
||||
(2335), `value-sources.ts` (1889), `types.ts` (1062). Highest-churn
|
||||
modules; mixed UI / state / network responsibilities.
|
||||
- [ ] **Layering reversal**: introduce a neutral `domain/` package and move
|
||||
shared DTOs (`FilterInstance`, `CalibrationConfig`, etc.) into it so
|
||||
`storage/` no longer imports `core/`. Eliminates 7+ layering
|
||||
violations and the lazy-import hacks used to break the resulting
|
||||
circulars.
|
||||
- [ ] **`main.py` boot refactor** — extract import-time side effects into
|
||||
`bootstrap.py` + `create_app()` factory. `lifespan()` becomes the
|
||||
single place that wires stores and managers.
|
||||
- [ ] **DI consolidation** — replace `api/dependencies.py` getter sprawl
|
||||
(30+ `get_*()` functions reading a process-global `_deps` dict) with
|
||||
a single typed `get_container()` dependency. Makes test-overrides
|
||||
trivial; ban direct getter calls in handler bodies.
|
||||
- [ ] **Exception hierarchy** — define `ledgrab/errors.py` (`LedGrabError`,
|
||||
`NotFoundError`, `ValidationError`, `RemoteUnavailableError`,
|
||||
`SSRFBlockedError`). Move HTTP translation into a FastAPI exception
|
||||
handler. Stop raising `HTTPException` from `utils/safe_source.py`.
|
||||
- [ ] **Lazy-import audit** — 289 in-function `from ledgrab.*` imports.
|
||||
Specifically `core/processing/daylight_settings.py` imports
|
||||
`api.dependencies` (core → api inversion). Pass the database in via
|
||||
the constructor instead of service-locator lookup.
|
||||
|
||||
## Performance (profile before applying)
|
||||
|
||||
- [ ] **`composite_stream.py` blend modes** — pre-allocate scratch buffers
|
||||
in `_blend_override / overlay / hard_light / soft_light / difference
|
||||
/ exclusion`. Each currently allocates per frame (`mul`, `scr`,
|
||||
`blended`, `np.where(...)`). At 100 LEDs × 30 fps × N layers this
|
||||
adds up.
|
||||
- [ ] **`mapped_stream` / `composite_stream` zone resize** — replace the
|
||||
per-channel `np.interp` calls with a cached `floor/ceil/frac` LUT
|
||||
(same trick as `wled_target_processor._fit_to_device`) or a single
|
||||
`cv2.resize` call on the (N,3) array. `np.interp` allocates a new
|
||||
`float64` array per channel per frame even on cache-hit.
|
||||
- [ ] **`processed_stream._processing_loop`** — add ping-pong output
|
||||
buffers and pass them as `out=` to filter `process_strip()` calls.
|
||||
Today every filter that returns a fresh allocation costs us a copy
|
||||
per frame. Also: the loop uses `time.sleep` instead of an
|
||||
event-driven wait on the input stream — input updates faster than
|
||||
30 fps see up to `frame_time` of latency.
|
||||
- [ ] **`mqtt_client.py` `send_pixels`** — add a binary publish path (or
|
||||
at minimum cache the outer dict skeleton). Today every frame
|
||||
`pixels.tolist()` + `json.dumps` for ~300 LEDs × 30 fps × N devices.
|
||||
- [ ] **Frontend `static/js/features/color-strips/test.ts`** — cache
|
||||
`ImageData` per canvas (`canvas._imageData`); only re-create on
|
||||
dimension change; use a `Uint32Array` view to copy pixels in one
|
||||
loop instead of the per-pixel JS loop. Border-overlay rebuild on
|
||||
every frame should also be debounced to dimension changes only.
|
||||
- [ ] **`ws_stream.py` composite branch** — pre-allocate a `bytearray`
|
||||
sized to the largest frame and write into slices instead of
|
||||
`b"".join(tobytes()) per layer` every iteration. Same anti-pattern
|
||||
in `wled_target_processor._broadcast_led_preview`.
|
||||
- [ ] **Preview broadcast slow-client guard** — `asyncio.gather` over
|
||||
preview clients waits for the slowest. Move to `asyncio.wait` with a
|
||||
timeout and drop slow clients, or fire-and-forget with a
|
||||
`ws.application_state` filter.
|
||||
|
||||
## Security (deferred — non-trivial or design-sensitive)
|
||||
|
||||
- [ ] **Content-Security-Policy header** — would need careful tuning
|
||||
because the UI uses inline event handlers / Jinja templates.
|
||||
Mis-set CSP would break the app silently. Defer until templates can
|
||||
move to event-delegated handlers, then add a strict policy.
|
||||
- [x] **`api/auth.py` exception specificity** — done in the 2026-05-23
|
||||
pass; see top of file.
|
||||
- [ ] **Hue bridge cert pinning** — `httpx.AsyncClient(verify=False)` for
|
||||
Hue bridge (self-signed cert by design). Should record the
|
||||
certificate fingerprint at pairing time and pin it on subsequent
|
||||
requests; otherwise an on-path attacker can MITM the bridge.
|
||||
|
||||
## Mechanical / code-quality (low risk, high line-count)
|
||||
|
||||
- [ ] **i18n parity** — confirmed **328** keys missing in `ru.json` and
|
||||
**325** missing in `zh.json` against the canonical English file.
|
||||
Translation work — needs a native speaker, not a machine-translation
|
||||
pass. Run `py scripts/diff_locale_keys.py` (or copy the diff block
|
||||
out of the 2026-05-23 pass log) to get the exact key list.
|
||||
- [x] **`Optional[T]` → `T | None`** — done; `UP007` now enforced via
|
||||
`pyproject.toml` so the rule prevents regressions.
|
||||
- [ ] **Hot-path `logger.error(f"...")` → `logger.error("... %s", e)`**
|
||||
lazy-eval — 658 sites flagged by `ruff --select G004`. Deferred
|
||||
because it is genuinely cosmetic at ERROR level (always emitted)
|
||||
and the cumulative cost is negligible. Worth doing if/when ruff
|
||||
gains a safe autofix, or as a Codemod in a dedicated session.
|
||||
- [x] **Remaining `(window as any)` sites** — 59 migrated to typed
|
||||
`window.<name>` access; the 7 surviving sites use dynamic string
|
||||
indexing and are documented as the legitimate exception.
|
||||
- [x] **Magic numbers → named constants** — done; see `processed_stream`
|
||||
and `wled_target_processor` constants at the top of each module.
|
||||
- [ ] **Standardise `from __future__ import annotations`** — partially
|
||||
mooted by the UP007 cleanup. Files that previously relied on
|
||||
`Optional`/`Union` no longer need the future import; the few that
|
||||
already use `__future__` keep it for forward-reference convenience.
|
||||
A blanket policy would still help — leave as a stylistic followup.
|
||||
|
||||
## Test gaps
|
||||
|
||||
- [x] **Route-level integration test** for the WLED scheme inference —
|
||||
done; covers create + update in `tests/api/routes/test_devices_routes.py::TestWLEDSchemeInference`.
|
||||
- [x] **IPv6 public address regression** — done; pinned in
|
||||
`tests/test_url_scheme.py` for both bracketless and bracketed forms.
|
||||
|
||||
## Pre-existing issues surfaced during the audit (not in our diff)
|
||||
|
||||
These were flagged by the auditors but predate the review session — kept
|
||||
here as a future-work backlog:
|
||||
|
||||
- [x] **`icon-select.ts:_buildGrid` `item.icon` is interpolated raw** —
|
||||
audited; all callers pass project-owned literals or table-lookup
|
||||
results. Added a runtime sanitiser as defence-in-depth.
|
||||
- [x] **`devices.py` `manager.update_device_info(device_url=update_data.url)`**
|
||||
None-on-PATCH path — fixed; now falls back to `existing.url`.
|
||||
- [ ] **`asyncio.gather` over uncapped client lists** in preview broadcasts
|
||||
— slow clients block the loop. Already noted under Performance
|
||||
above; pre-existing.
|
||||
@@ -30,23 +30,39 @@ val ledgrabVersionCode: Int = run {
|
||||
|
||||
android {
|
||||
namespace = "com.ledgrab.android"
|
||||
compileSdk = 34
|
||||
// SDK 35 (Android 15) — required for Play Store from Aug 2025 onward.
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.ledgrab.android"
|
||||
minSdk = 24 // Android 7.0 — covers nearly all TV boxes
|
||||
targetSdk = 34
|
||||
targetSdk = 35
|
||||
// Derived from git commit count (or ANDROID_VERSION_CODE env var
|
||||
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
|
||||
// sideload updates silently refused to install.
|
||||
versionCode = ledgrabVersionCode
|
||||
versionName = "0.4.2"
|
||||
versionName = "0.9.0"
|
||||
|
||||
// ABI selection. Detect armeabi-v7a wheel presence and opt the
|
||||
// ABI in only when the matching pydantic-core wheel is on disk —
|
||||
// otherwise Chaquopy would fail the build searching for it. The
|
||||
// build script (build-scripts/build-pydantic-core.sh) is the
|
||||
// source of truth for which ABIs we *can* ship.
|
||||
val v7Wheel = file("$rootDir/wheels").listFiles().orEmpty()
|
||||
.any { it.name.startsWith("pydantic_core-") && it.name.contains("armeabi_v7a") }
|
||||
val ledgrabAbis = buildList {
|
||||
add("arm64-v8a")
|
||||
add("x86_64")
|
||||
add("x86")
|
||||
if (v7Wheel) add("armeabi-v7a")
|
||||
}
|
||||
ndk {
|
||||
// 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")
|
||||
// arm64-v8a is the primary target (real TV hardware).
|
||||
// x86_64/x86 cover emulators.
|
||||
// armeabi-v7a is opt-in: many pre-2018 Mecool/X96/H96 TV boxes
|
||||
// still ship 32-bit ARMv7 — when a wheel exists in wheels/ we
|
||||
// automatically include the ABI in builds.
|
||||
abiFilters += ledgrabAbis
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,9 +70,12 @@ android {
|
||||
// Each split contains only one native ABI's shared libraries + wheels.
|
||||
splits {
|
||||
abi {
|
||||
val v7Wheel = file("$rootDir/wheels").listFiles().orEmpty()
|
||||
.any { it.name.startsWith("pydantic_core-") && it.name.contains("armeabi_v7a") }
|
||||
isEnable = true
|
||||
reset()
|
||||
include("arm64-v8a", "x86_64", "x86")
|
||||
if (v7Wheel) include("armeabi-v7a")
|
||||
isUniversalApk = true // also produce a fat APK for sideloading
|
||||
}
|
||||
}
|
||||
@@ -96,10 +115,21 @@ android {
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
signingConfig = if (hasCiSigning) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
// Refuse to silently sign release APKs with the debug
|
||||
// keystore — that's how a debug-signed release accidentally
|
||||
// ships. CI must provide all four signing env vars. If a
|
||||
// local "release" build is genuinely intended for testing,
|
||||
// set ANDROID_ALLOW_DEBUG_SIGNED_RELEASE=1 to opt out.
|
||||
val allowDebugSigned =
|
||||
System.getenv("ANDROID_ALLOW_DEBUG_SIGNED_RELEASE") == "1"
|
||||
signingConfig = when {
|
||||
hasCiSigning -> signingConfigs.getByName("release")
|
||||
allowDebugSigned -> signingConfigs.getByName("debug")
|
||||
else -> throw GradleException(
|
||||
"Release builds require signing env vars " +
|
||||
"(ANDROID_KEYSTORE_PATH/PASSWORD, ANDROID_KEY_ALIAS/PASSWORD). " +
|
||||
"Set ANDROID_ALLOW_DEBUG_SIGNED_RELEASE=1 to force a debug-signed release."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,8 +205,15 @@ dependencies {
|
||||
implementation("androidx.lifecycle:lifecycle-service:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||
// SplashScreen API — keeps a friendly logo on screen while Chaquopy
|
||||
// unpacks the Python stdlib on first launch (can take 1-3s).
|
||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||
// QR code generation for displaying server URL on TV
|
||||
implementation("com.google.zxing:core:3.5.3")
|
||||
// EncryptedSharedPreferences (Android Keystore-backed) for the per-install
|
||||
// server API key (see ApiKeyManager). Falls back to plain SharedPreferences
|
||||
// when the keystore is unavailable.
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
|
||||
// driving Adalight/AmbiLED controllers plugged into Android TV boxes.
|
||||
implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"
|
||||
tools:targetApi="s" />
|
||||
|
||||
<!-- BLE hardware — required=false so non-BT boxes still install. -->
|
||||
<uses-feature
|
||||
@@ -26,13 +27,57 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<!-- MediaProjection requires a foreground service -->
|
||||
<!-- Foreground service permissions.
|
||||
FOREGROUND_SERVICE_MEDIA_PROJECTION: required on API 34+ for the
|
||||
MediaProjection capture path.
|
||||
FOREGROUND_SERVICE_SPECIAL_USE: required on API 34+ for the root
|
||||
screenrecord capture path (it doesn't use MediaProjection).
|
||||
Both are declared because the service may run in either mode. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<!-- FOREGROUND_SERVICE_CAMERA (API 34+): required to keep camera access while
|
||||
the app is backgrounded during on-device webcam capture. The service is
|
||||
promoted with the `camera` FGS type ONLY when CAMERA is already granted
|
||||
(see CaptureService.onStartCommand) — unlike audio playback capture (which
|
||||
rides the MediaProjection token under the mediaProjection type), the camera
|
||||
has no such coupling and needs its own FGS type to survive backgrounding. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
||||
|
||||
<!-- POST_NOTIFICATIONS for Android 13+ foreground service notification -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- RECORD_AUDIO for on-device system-playback capture (AudioPlaybackCapture,
|
||||
API 29+) feeding audio-reactive lighting. Runtime "dangerous" permission,
|
||||
requested in MainActivity; capture degrades gracefully when denied.
|
||||
Playback capture runs under the existing mediaProjection FGS type, so no
|
||||
FOREGROUND_SERVICE_MICROPHONE / microphone FGS type is needed (that would
|
||||
only be required if the mic-fallback path ran inside the service). -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<!-- CAMERA for on-device webcam capture (Camera2). Runtime "dangerous"
|
||||
permission, requested in MainActivity gated on FEATURE_CAMERA_ANY so
|
||||
camera-less TV boxes never see the prompt; capture degrades gracefully
|
||||
when denied. The camera is opened ON DEMAND (only while a camera
|
||||
capture source is active). To keep capturing after the app is
|
||||
backgrounded, the service is promoted with the `camera` FGS type
|
||||
(FOREGROUND_SERVICE_CAMERA above) — but only when CAMERA is already
|
||||
granted, so a camera-less / not-yet-granted box never risks a failed
|
||||
service start. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- PACKAGE_USAGE_STATS — read the foreground app for the "Application"
|
||||
automation rule (foreground app -> activate scene) via UsageStatsManager.
|
||||
A special-access permission: it can't be granted at runtime; the user
|
||||
toggles it under Settings > Usage access (opened from MainActivity).
|
||||
tools:ignore="ProtectedPermissions" silences the build warning that this
|
||||
is a system/signature-level permission — it is honoured as a user-grantable
|
||||
special access. NO QUERY_ALL_PACKAGES is needed: matching only compares the
|
||||
foreground package NAME, and the app picker uses LauncherApps. -->
|
||||
<uses-permission
|
||||
android:name="android.permission.PACKAGE_USAGE_STATS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<!-- Autostart on boot — BootReceiver spawns CaptureService in root
|
||||
mode so capture resumes without the user touching the remote. -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
@@ -57,30 +102,70 @@
|
||||
android:name="android.hardware.usb.host"
|
||||
android:required="false" />
|
||||
|
||||
<!-- Camera hardware — for on-device webcam capture. required=false so
|
||||
camera-less TV boxes (the common case) still install; the camera
|
||||
engine simply reports no displays on such devices. camera.any covers
|
||||
built-in (front/back) and external/USB-UVC cameras the platform
|
||||
routes through Camera2. -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera.any"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".LedGrabApp"
|
||||
android:allowBackup="false"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:banner="@drawable/ic_launcher"
|
||||
android:banner="@drawable/banner_tv"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@style/Theme.LedGrab">
|
||||
|
||||
<!-- TV launcher activity -->
|
||||
<!-- TV launcher activity. Boots through the SplashScreen theme so
|
||||
the (sometimes multi-second) Chaquopy stdlib unpack doesn't
|
||||
show as a black screen on first launch. -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.LedGrab.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Foreground service for screen capture + Python server -->
|
||||
<!-- Foreground service for screen capture + Python server.
|
||||
Declares BOTH mediaProjection AND specialUse: only one is
|
||||
active at a time but Android needs to see the union of
|
||||
possible types up-front so it doesn't kill the service when
|
||||
we promote it with a different type at runtime.
|
||||
FOREGROUND_SERVICE_TYPE_SPECIAL_USE on API 34+ requires the
|
||||
PROPERTY_SPECIAL_USE_FGS_SUBTYPE rationale below. -->
|
||||
<service
|
||||
android:name=".CaptureService"
|
||||
android:foregroundServiceType="mediaProjection"
|
||||
android:exported="false" />
|
||||
android:foregroundServiceType="mediaProjection|specialUse|camera"
|
||||
android:exported="false">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Root-mode screen capture for ambient LED sync. Uses /system/bin/screenrecord on rooted devices to avoid MediaProjection's persistent capture indicator overlay, which is required for the always-on ambient-lighting use case." />
|
||||
</service>
|
||||
|
||||
<!-- Notification capture — a NotificationListenerService bound by
|
||||
system_server. exported="true" is REQUIRED here (the system binds
|
||||
it cross-process) and intentionally diverges from CaptureService
|
||||
(exported="false"); access is gated by the system-held
|
||||
BIND_NOTIFICATION_LISTENER_SERVICE permission, so no new
|
||||
<uses-permission> is needed. The user grants access via
|
||||
Settings > Notification access (opened from MainActivity). -->
|
||||
<service
|
||||
android:name=".LedGrabNotificationListener"
|
||||
android:label="@string/notification_listener_label"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Autostart — fires on device boot (and package replace).
|
||||
On rooted devices, launches CaptureService directly so capture
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Persists the per-install API key for the embedded FastAPI server.
|
||||
*
|
||||
* The server's auth gate ([ledgrab.api.auth]) requires a Bearer token
|
||||
* for any non-loopback request when ``auth.api_keys`` is configured.
|
||||
* Without a key, LAN clients (phone, laptop) get 401 — which is the
|
||||
* server's secure default but breaks the QR-scan workflow.
|
||||
*
|
||||
* This class generates one key per install (random 32-byte → 64-char
|
||||
* hex), persists it to SharedPreferences, and exposes it to:
|
||||
* - [PythonBridge] which sets ``LEDGRAB_AUTH__API_KEYS=android:<key>``
|
||||
* before uvicorn starts.
|
||||
* - [MainActivity] which embeds the key as a URL fragment
|
||||
* (``http://ip:port/#k=<key>``) in the QR. Fragments are never sent
|
||||
* to the server in HTTP requests, so the key doesn't appear in
|
||||
* access logs.
|
||||
*/
|
||||
class ApiKeyManager(context: Context) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
// Prefer Android-Keystore-backed EncryptedSharedPreferences for the API
|
||||
// key. If the keystore is unavailable (some OEM TV-box ROMs ship a broken
|
||||
// or absent keystore, or a key got corrupted), creation throws — fall back
|
||||
// to plain SharedPreferences so a keystore failure NEVER bricks the local
|
||||
// API key (which would 401 every LAN client).
|
||||
private val prefs: SharedPreferences
|
||||
|
||||
init {
|
||||
val (store, isEncrypted) = buildPrefs(appContext)
|
||||
prefs = store
|
||||
// Only run the plain→encrypted migration when the encrypted store is
|
||||
// actually available; on the degraded plain path there is nothing to
|
||||
// migrate INTO (and recoverLegacyKey reads the backup directly).
|
||||
if (isEncrypted) migrateLegacyKeyIfPresent()
|
||||
}
|
||||
|
||||
// Once we've materialised a key in this process, cache it so
|
||||
// subsequent reads don't hit prefs and don't risk re-checking
|
||||
// length under contention.
|
||||
@Volatile private var cached: String? = null
|
||||
private val lock = Any()
|
||||
|
||||
/** Persistent random API key, generated lazily on first access. */
|
||||
val apiKey: String
|
||||
get() = getOrCreateKey()
|
||||
|
||||
/** Force a new key. Useful if a user thinks the QR was photographed. */
|
||||
fun rotate(): String {
|
||||
synchronized(lock) {
|
||||
val next = generateKey()
|
||||
// apply() is fine for rotation — by definition the user
|
||||
// initiated this and will see the new QR; the worst case
|
||||
// on crash is they need to re-rotate.
|
||||
prefs.edit().putString(KEY_API_KEY, next).apply()
|
||||
cached = next
|
||||
Log.i(TAG, "Rotated API key")
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreateKey(): String {
|
||||
cached?.let { return it }
|
||||
synchronized(lock) {
|
||||
// Double-checked under the lock.
|
||||
cached?.let { return it }
|
||||
val existing = prefs.getString(KEY_API_KEY, null)
|
||||
if (existing != null && existing.length >= MIN_KEY_LENGTH) {
|
||||
cached = existing
|
||||
return existing
|
||||
}
|
||||
// Before minting a fresh key, fall back to any key still in the
|
||||
// legacy plain store (covers a failed/partial encrypted migration:
|
||||
// commit() can return false WITHOUT throwing, so migration may have
|
||||
// left the live key only in the legacy file). Rotating the
|
||||
// per-install key would 401 every already-paired client, so we
|
||||
// generate a brand-new key ONLY when no key exists anywhere.
|
||||
recoverLegacyKey()?.let { recovered ->
|
||||
// Best-effort persist into the encrypted store; cache regardless
|
||||
// so we still return the recovered key if the write keeps failing.
|
||||
runCatching { prefs.edit().putString(KEY_API_KEY, recovered).commit() }
|
||||
cached = recovered
|
||||
Log.i(TAG, "Recovered existing API key from legacy storage")
|
||||
return recovered
|
||||
}
|
||||
val generated = generateKey()
|
||||
// commit() (synchronous disk write) on the FIRST write so
|
||||
// the key is durable before MainActivity encodes it into a
|
||||
// QR. If the process is killed between QR display and the
|
||||
// async write landing, the user's phone would scan a key
|
||||
// the server never learned about. Subsequent rotates can
|
||||
// safely use apply().
|
||||
prefs.edit().putString(KEY_API_KEY, generated).commit()
|
||||
cached = generated
|
||||
Log.i(TAG, "Generated new API key (length=${generated.length})")
|
||||
return generated
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the backing store, preferring EncryptedSharedPreferences. Returns
|
||||
* (store, isEncrypted). Any keystore failure falls back to the plain prefs
|
||||
* file so the local API key is never lost on a broken-keystore device.
|
||||
*/
|
||||
private fun buildPrefs(context: Context): Pair<SharedPreferences, Boolean> {
|
||||
return try {
|
||||
createEncrypted(context) to true
|
||||
} catch (e: Exception) {
|
||||
// The keystore can become invalidated (OS upgrade, device restore,
|
||||
// OEM keystore bug), after which create() throws on EVERY launch and
|
||||
// the corrupt encrypted file is never cleaned up — degrading to plain
|
||||
// prefs forever and (because the live key was only in the encrypted
|
||||
// store) rotating the per-install key on the next mint, 401-ing every
|
||||
// paired client. Self-heal once: delete the corrupt store + master key
|
||||
// alias and retry create() before degrading.
|
||||
Log.w(TAG, "EncryptedSharedPreferences unavailable, attempting one-time reset: ${e.message}")
|
||||
runCatching {
|
||||
context.deleteSharedPreferences(ENCRYPTED_PREFS_NAME)
|
||||
runCatching {
|
||||
val ks = java.security.KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
if (ks.containsAlias(MasterKey.DEFAULT_MASTER_KEY_ALIAS)) {
|
||||
ks.deleteEntry(MasterKey.DEFAULT_MASTER_KEY_ALIAS)
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Master-key alias cleanup failed: ${it.message}") }
|
||||
createEncrypted(context) to true
|
||||
}.getOrElse {
|
||||
// Still failing after reset — degrade to plain prefs rather than
|
||||
// crashing. Worst case the key is stored unencrypted on a
|
||||
// single-user TV box, which is the pre-existing behaviour.
|
||||
Log.w(TAG, "EncryptedSharedPreferences still unavailable after reset, using plain prefs: ${it.message}")
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) to false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createEncrypted(context: Context): SharedPreferences {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
ENCRYPTED_PREFS_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration: if a key exists in the legacy plain-text prefs file
|
||||
* (from before encrypted storage), copy it into the encrypted store and
|
||||
* remove the plain copy. Preserves the existing key so already-scanned QR
|
||||
* clients keep working — generating a fresh key here would silently 401
|
||||
* every LAN client (see the Data Migration Policy in CLAUDE.md).
|
||||
*/
|
||||
private fun migrateLegacyKeyIfPresent() {
|
||||
// Don't migrate if the encrypted store already holds a key.
|
||||
if (!prefs.getString(KEY_API_KEY, null).isNullOrEmpty()) return
|
||||
runCatching {
|
||||
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val legacyKey = legacy.getString(KEY_API_KEY, null)
|
||||
if (legacyKey != null && legacyKey.length >= MIN_KEY_LENGTH) {
|
||||
// commit() returns false on write failure WITHOUT throwing, so the
|
||||
// runCatching wrapper alone does NOT protect this path. Verify the
|
||||
// encrypted store both committed AND reads back the identical value
|
||||
// before touching the legacy copy — otherwise a silent write
|
||||
// failure could delete the only surviving copy of the key and
|
||||
// rotate it on next launch (401s every paired client — the exact
|
||||
// silent-data-loss the Data Migration Policy forbids).
|
||||
val ok = prefs.edit().putString(KEY_API_KEY, legacyKey).commit()
|
||||
if (ok && prefs.getString(KEY_API_KEY, null) == legacyKey) {
|
||||
// Keep the value as a .migrated backup (don't hard-delete) per
|
||||
// the migration policy; remove only the live legacy key so the
|
||||
// plaintext copy no longer answers reads.
|
||||
legacy.edit()
|
||||
.putString(KEY_API_KEY_MIGRATED, legacyKey)
|
||||
.remove(KEY_API_KEY)
|
||||
.apply()
|
||||
Log.i(TAG, "Migrated API key from plain to encrypted storage")
|
||||
} else {
|
||||
// Leave the legacy key untouched; getOrCreateKey() will recover
|
||||
// it via recoverLegacyKey() rather than minting a fresh one.
|
||||
Log.w(TAG, "Encrypted key write unverified — keeping legacy key, not migrating")
|
||||
}
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Legacy API key migration failed: ${it.message}") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover a still-present key from the legacy plain store — either the live
|
||||
* key (failed/never-run migration) or the `.migrated` backup. Returns null
|
||||
* only when no valid key survives.
|
||||
*
|
||||
* This MUST run on the degraded plain-prefs path too (not just the encrypted
|
||||
* path): after a successful migration the live key is moved to the
|
||||
* `.migrated` backup in this same plain file, so when the keystore later
|
||||
* fails and we degrade to plain prefs, the backup is the only surviving
|
||||
* copy. Returning null here (the previous `if (!encrypted) return null`
|
||||
* guard) would mint a fresh key and rotate the per-install key, 401-ing every
|
||||
* paired client.
|
||||
*/
|
||||
private fun recoverLegacyKey(): String? {
|
||||
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val candidate = legacy.getString(KEY_API_KEY, null)
|
||||
?: legacy.getString(KEY_API_KEY_MIGRATED, null)
|
||||
return candidate?.takeIf { it.length >= MIN_KEY_LENGTH }
|
||||
}
|
||||
|
||||
private fun generateKey(): String {
|
||||
val bytes = ByteArray(KEY_BYTES)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
// Hex-encode so the key survives copy/paste, URL fragments, env
|
||||
// vars, and YAML config without escaping concerns. Mask to 0xff
|
||||
// first — Kotlin's Byte is signed, and `%02x` on a negative
|
||||
// Byte sign-extends to an 8-char hex string ("ffffffff" instead
|
||||
// of "ff"), which would produce an invalid key.
|
||||
return bytes.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ApiKeyManager"
|
||||
private const val PREFS_NAME = "ledgrab_auth"
|
||||
private const val ENCRYPTED_PREFS_NAME = "ledgrab_auth_enc"
|
||||
private const val KEY_API_KEY = "api_key"
|
||||
// Backup of a migrated legacy key, kept in the plain store per the
|
||||
// Data Migration Policy (never hard-delete user data on rename/move).
|
||||
private const val KEY_API_KEY_MIGRATED = "api_key_migrated"
|
||||
private const val KEY_BYTES = 32
|
||||
private const val MIN_KEY_LENGTH = 32
|
||||
|
||||
/** Label used as the LEDGRAB_AUTH__API_KEYS map key. */
|
||||
const val LABEL = "android"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioPlaybackCaptureConfiguration
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.media.projection.MediaProjection
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* Captures audio with [AudioRecord] and pushes interleaved float32 PCM to
|
||||
* the LedGrab Python server via [PythonBridge], where the
|
||||
* `android_audio_engine` feeds it into the unchanged audio-analysis
|
||||
* pipeline.
|
||||
*
|
||||
* Two sources:
|
||||
* - [start] — system playback capture via `AudioPlaybackCapture` (API 29+),
|
||||
* reusing the same [MediaProjection] token the app already holds for
|
||||
* screen capture. This is the primary path on the consent flow.
|
||||
* - [startMic] — microphone fallback (`AudioSource.MIC`) for paths with no
|
||||
* MediaProjection (root mode) or API < 29.
|
||||
*
|
||||
* Mirrors [ScreenCapture]'s shape: a dedicated capture thread, a single
|
||||
* reusable cross-JNI buffer (no per-block allocation → no GC churn on
|
||||
* low-end TV boxes), and graceful teardown in [stop].
|
||||
*
|
||||
* The capture format is negotiated by [AudioRecord]; the **actual**
|
||||
* channel count and sample rate are read back and forwarded to
|
||||
* `configureAudio` so the Python analyzer's interleaving matches the bytes
|
||||
* we push (e.g. a stereo request that the device satisfies as mono).
|
||||
*/
|
||||
class AudioCapture(
|
||||
private val projection: MediaProjection?,
|
||||
private val bridge: PythonBridge,
|
||||
private val sampleRate: Int = 48000,
|
||||
private val channels: Int = 2,
|
||||
private val chunkFrames: Int = 1024,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "AudioCapture"
|
||||
private const val BYTES_PER_FLOAT = 4
|
||||
}
|
||||
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var captureThread: Thread? = null
|
||||
@Volatile private var running = false
|
||||
|
||||
/**
|
||||
* Start system playback capture (API 29+). Requires the app to hold
|
||||
* RECORD_AUDIO and a valid [projection]. Returns true if capture began.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start(): Boolean {
|
||||
if (running) return true
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
Log.i(TAG, "Playback capture needs API 29+; skipping (have ${Build.VERSION.SDK_INT})")
|
||||
return false
|
||||
}
|
||||
val proj = projection
|
||||
if (proj == null) {
|
||||
Log.i(TAG, "No MediaProjection; playback capture unavailable")
|
||||
return false
|
||||
}
|
||||
|
||||
val config = AudioPlaybackCaptureConfiguration.Builder(proj)
|
||||
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.addMatchingUsage(AudioAttributes.USAGE_GAME)
|
||||
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
|
||||
.build()
|
||||
|
||||
val record = try {
|
||||
AudioRecord.Builder()
|
||||
.setAudioFormat(audioFormat())
|
||||
.setBufferSizeInBytes(bufferBytes())
|
||||
.setAudioPlaybackCaptureConfig(config)
|
||||
.build()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to build playback AudioRecord: ${e.message}")
|
||||
return false
|
||||
}
|
||||
return begin(record, "playback")
|
||||
}
|
||||
|
||||
/**
|
||||
* Start microphone capture (fallback). Works on API 24+ and needs no
|
||||
* MediaProjection. Requires RECORD_AUDIO. Returns true if capture began.
|
||||
*
|
||||
* ⚠️ SECURITY/POLICY: currently UNWIRED (no caller). Microphone capture is
|
||||
* a materially different posture than playback capture — it records real
|
||||
* room audio (bystander voices). Before wiring this into [CaptureService]:
|
||||
* - add FOREGROUND_SERVICE_MICROPHONE permission + the `microphone` FGS
|
||||
* type (on API 34+ the service is killed without it), and
|
||||
* - add the Play Store privacy disclosure for microphone use,
|
||||
* - re-trigger a security review.
|
||||
* Do NOT call this from inside the foreground service without the above.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
fun startMic(): Boolean {
|
||||
if (running) return true
|
||||
val record = try {
|
||||
AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(audioFormat())
|
||||
.setBufferSizeInBytes(bufferBytes())
|
||||
.build()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to build mic AudioRecord: ${e.message}")
|
||||
return false
|
||||
}
|
||||
return begin(record, "mic")
|
||||
}
|
||||
|
||||
/** Stop capturing and release all resources. Idempotent. */
|
||||
fun stop() {
|
||||
running = false
|
||||
// AudioRecord.stop() unblocks a pending READ_BLOCKING read within
|
||||
// milliseconds, so the loop sees running=false and returns well inside
|
||||
// the 500ms join window — release() below won't race a live read.
|
||||
// (Mirrors ScreenCapture's bounded join.)
|
||||
runCatching { audioRecord?.stop() }
|
||||
captureThread?.let { runCatching { it.join(500) } }
|
||||
captureThread = null
|
||||
runCatching { audioRecord?.release() }
|
||||
audioRecord = null
|
||||
runCatching { bridge.shutdownAudio() }
|
||||
Log.i(TAG, "Audio capture stopped")
|
||||
}
|
||||
|
||||
// ── internals ──────────────────────────────────────────────────────
|
||||
|
||||
private fun begin(record: AudioRecord, mode: String): Boolean {
|
||||
if (record.state != AudioRecord.STATE_INITIALIZED) {
|
||||
Log.e(TAG, "AudioRecord ($mode) failed to initialize")
|
||||
runCatching { record.release() }
|
||||
return false
|
||||
}
|
||||
val actualChannels = record.channelCount.coerceAtLeast(1)
|
||||
val actualRate = record.sampleRate
|
||||
|
||||
// Confirm recording actually started before reporting success —
|
||||
// startRecording() can throw (exclusive-capture contention) or
|
||||
// leave the record in a non-recording state, in which case read()
|
||||
// would only ever return errors.
|
||||
val started = runCatching { record.startRecording() }.isSuccess &&
|
||||
record.recordingState == AudioRecord.RECORDSTATE_RECORDING
|
||||
if (!started) {
|
||||
Log.e(TAG, "AudioRecord ($mode) failed to start recording")
|
||||
runCatching { record.release() }
|
||||
return false
|
||||
}
|
||||
|
||||
// Recording confirmed — tell Python the real negotiated format
|
||||
// before frames flow, so the analyzer's channel/sample-rate match
|
||||
// the interleaving we push.
|
||||
bridge.configureAudio(actualRate, actualChannels, chunkFrames)
|
||||
|
||||
audioRecord = record
|
||||
running = true
|
||||
captureThread = Thread(
|
||||
{ captureLoop(record, actualChannels) },
|
||||
"LedGrab-AudioCapture",
|
||||
).also { it.start() }
|
||||
Log.i(TAG, "Audio capture started ($mode, sr=$actualRate ch=$actualChannels chunk=$chunkFrames)")
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking read loop. Accumulates into fixed `chunkFrames * channels`
|
||||
* float blocks and pushes only COMPLETE blocks — [AudioRecord.read]
|
||||
* returns a variable count, so partial reads are stitched here rather
|
||||
* than handed to Python as ragged chunks (the analyzer requires
|
||||
* whole-frame, ≤ chunk-size blocks).
|
||||
*/
|
||||
private fun captureLoop(record: AudioRecord, actualChannels: Int) {
|
||||
val blockFloats = chunkFrames * actualChannels
|
||||
val floatBuf = FloatArray(blockFloats)
|
||||
// Reusable little-endian byte buffer — Python copies on push, so the
|
||||
// same backing array is safe to overwrite next block. Default
|
||||
// ByteBuffer order is BIG_ENDIAN, which would corrupt every sample;
|
||||
// LITTLE_ENDIAN matches numpy's native float32 on all Android ABIs.
|
||||
val byteBuf = ByteArray(blockFloats * BYTES_PER_FLOAT)
|
||||
val floatView = ByteBuffer.wrap(byteBuf).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
||||
|
||||
var filled = 0
|
||||
while (running) {
|
||||
val n = record.read(floatBuf, filled, blockFloats - filled, AudioRecord.READ_BLOCKING)
|
||||
if (n < 0) {
|
||||
if (running) {
|
||||
// A negative read (e.g. ERROR_DEAD_OBJECT after an audio-route
|
||||
// change, ERROR_INVALID_OPERATION) means this AudioRecord is
|
||||
// finished. Deactivate the Python engine so is_available() stops
|
||||
// advertising a dead stream and the audio-reactive consumer isn't
|
||||
// left polling an empty queue forever. We're on the capture thread,
|
||||
// so we can't call stop() (it would self-join) — just flip running
|
||||
// and shut the engine down; onDestroy's stop() releases the record.
|
||||
Log.w(TAG, "AudioRecord.read error: $n — stopping audio capture")
|
||||
running = false
|
||||
runCatching { bridge.shutdownAudio() }
|
||||
}
|
||||
break
|
||||
}
|
||||
filled += n
|
||||
if (filled < blockFloats) continue
|
||||
|
||||
floatView.clear()
|
||||
floatView.put(floatBuf, 0, blockFloats)
|
||||
bridge.pushAudio(byteBuf)
|
||||
filled = 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun channelMask(): Int =
|
||||
if (channels >= 2) AudioFormat.CHANNEL_IN_STEREO else AudioFormat.CHANNEL_IN_MONO
|
||||
|
||||
private fun audioFormat(): AudioFormat =
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(channelMask())
|
||||
.build()
|
||||
|
||||
private fun bufferBytes(): Int {
|
||||
val minBuf = AudioRecord.getMinBufferSize(sampleRate, channelMask(), AudioFormat.ENCODING_PCM_FLOAT)
|
||||
// A few blocks of headroom so a slow consumer doesn't overrun the
|
||||
// hardware buffer between reads.
|
||||
val want = chunkFrames * channels * BYTES_PER_FLOAT * 4
|
||||
return if (minBuf > 0) maxOf(minBuf, want) else want
|
||||
}
|
||||
}
|
||||
@@ -103,12 +103,32 @@ object BleBridge {
|
||||
}
|
||||
|
||||
try {
|
||||
bleHandler.post { scanner.startScan(callback) }
|
||||
// startScan runs on the BLE handler thread; a denied
|
||||
// BLUETOOTH_SCAN throws SecurityException there, which would
|
||||
// crash the whole process (an uncaught exception on a handler
|
||||
// thread is fatal). Catch it inside the posted body and report.
|
||||
bleHandler.post {
|
||||
try {
|
||||
scanner.startScan(callback)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "BLUETOOTH_SCAN permission denied — scan skipped", e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "BLE startScan failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
Thread.sleep(timeoutMs)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
} finally {
|
||||
try { bleHandler.post { scanner.stopScan(callback) } } catch (_: SecurityException) {}
|
||||
bleHandler.post {
|
||||
try {
|
||||
scanner.stopScan(callback)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "BLUETOOTH_SCAN permission denied — stopScan skipped", e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "BLE stopScan failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
return seen.values.toList()
|
||||
}
|
||||
@@ -136,7 +156,18 @@ object BleBridge {
|
||||
newState == BluetoothProfile.STATE_CONNECTED
|
||||
&& status == BluetoothGatt.GATT_SUCCESS -> {
|
||||
Log.d(TAG, "GATT connected to $address, discovering services")
|
||||
// Runs on the BLE handler thread; a denied
|
||||
// BLUETOOTH_CONNECT throws SecurityException here, which
|
||||
// would crash the process. Catch and fail the connect.
|
||||
try {
|
||||
gatt.discoverServices()
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "BLUETOOTH_CONNECT denied during discoverServices", e)
|
||||
readyDeferred.complete(false)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "discoverServices failed: ${e.message}")
|
||||
readyDeferred.complete(false)
|
||||
}
|
||||
}
|
||||
newState == BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
Log.w(TAG, "GATT disconnected from $address (status=$status)")
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.ImageFormat
|
||||
import android.hardware.camera2.CameraCaptureSession
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraDevice
|
||||
import android.hardware.camera2.CameraManager
|
||||
import android.media.Image
|
||||
import android.media.ImageReader
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.util.Size
|
||||
import android.view.Surface
|
||||
import com.chaquo.python.PyObject
|
||||
import com.chaquo.python.Python
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Android camera bridge exposed to the Python server via Chaquopy.
|
||||
*
|
||||
* Wraps the Camera2 API into synchronous, blocking calls that can be
|
||||
* invoked from a Python thread (Chaquopy proxy threads are real OS
|
||||
* threads). The physical camera is opened **on demand** — Python's
|
||||
* `android_camera_engine` calls [startCamera] when a capture stream
|
||||
* initializes and [stopCamera] when it cleans up, so the camera-in-use
|
||||
* indicator and battery cost are limited to actual use.
|
||||
*
|
||||
* Each captured frame is converted YUV_420_888 → RGB and pushed to the
|
||||
* Python engine's `push_frame`, mirroring how [ScreenCapture] feeds
|
||||
* `mediaprojection_engine`. Camera2 callbacks run on a private
|
||||
* [HandlerThread] so they never touch the main looper.
|
||||
*
|
||||
* Python callers access the singleton via
|
||||
* `jclass("com.ledgrab.android.CameraBridge").INSTANCE` — see
|
||||
* `server/src/ledgrab/core/capture_engines/android_camera_engine.py`.
|
||||
*/
|
||||
object CameraBridge {
|
||||
private const val TAG = "CameraBridge"
|
||||
private const val ENGINE_MODULE = "ledgrab.core.capture_engines.android_camera_engine"
|
||||
private const val OPEN_TIMEOUT_MS = 8_000L
|
||||
private const val MAX_IMAGES = 2
|
||||
private const val TARGET_FPS = 20
|
||||
// "auto" capture size — balanced for ambient LED sampling (the LED
|
||||
// pipeline downscales anyway), kept modest so the per-frame YUV→RGB
|
||||
// conversion stays cheap on low-end TV boxes.
|
||||
private const val DEFAULT_W = 1280
|
||||
private const val DEFAULT_H = 720
|
||||
private const val BYTES_PER_RGB = 3
|
||||
|
||||
@Volatile private var appContext: Context? = null
|
||||
|
||||
// Dedicated looper thread so Camera2 callbacks don't land on main.
|
||||
private val camThread = HandlerThread("LedGrab-Camera").also { it.start() }
|
||||
private val camHandler = Handler(camThread.looper)
|
||||
|
||||
// Active session state — guarded by [lock]. One camera at a time.
|
||||
private val lock = Any()
|
||||
private var cameraDevice: CameraDevice? = null
|
||||
private var captureSession: CameraCaptureSession? = null
|
||||
private var imageReader: ImageReader? = null
|
||||
@Volatile private var running = false
|
||||
private var activeIndex = -1
|
||||
|
||||
// Cached Python engine module handle for the per-frame push fast path.
|
||||
@Volatile private var engineModule: PyObject? = null
|
||||
|
||||
// Reusable conversion buffers — sized once per session (output size is
|
||||
// fixed for the session), reused to avoid per-frame GC churn on TV boxes.
|
||||
private var rgbBuffer: ByteArray? = null
|
||||
private var yBuf: ByteArray? = null
|
||||
private var uBuf: ByteArray? = null
|
||||
private var vBuf: ByteArray? = null
|
||||
|
||||
// Monotonic frame pacing (mirrors ScreenCapture's accumulator).
|
||||
private val frameIntervalNanos = 1_000_000_000L / TARGET_FPS.coerceAtLeast(1)
|
||||
private var nextFrameNanos = 0L
|
||||
|
||||
/** Called once from [LedGrabApp.onCreate] to bind the application context. */
|
||||
@JvmStatic
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate cameras as a JSON array string the Python engine parses:
|
||||
* `[{"index":0,"name":"Back camera","facing":"back","cameraId":"0"}, ...]`
|
||||
*
|
||||
* Indices are stable (positional in [CameraManager.cameraIdList]) so
|
||||
* Python's `display_index` maps 1:1 to [startCamera]'s `index`.
|
||||
* Enumeration needs no CAMERA permission. Returns `[]` on any error.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun listCameras(): String {
|
||||
val arr = JSONArray()
|
||||
val ctx = appContext
|
||||
if (ctx == null) {
|
||||
Log.w(TAG, "listCameras: context not bound (init not called)")
|
||||
return arr.toString()
|
||||
}
|
||||
try {
|
||||
val mgr = ctx.getSystemService(Context.CAMERA_SERVICE) as CameraManager
|
||||
mgr.cameraIdList.forEachIndexed { idx, id ->
|
||||
val facing = facingOf(mgr, id)
|
||||
val name = when (facing) {
|
||||
"front" -> "Front camera"
|
||||
"back" -> "Back camera"
|
||||
"external" -> "External camera $idx"
|
||||
else -> "Camera $idx"
|
||||
}
|
||||
arr.put(
|
||||
JSONObject()
|
||||
.put("index", idx)
|
||||
.put("name", name)
|
||||
.put("facing", facing)
|
||||
.put("cameraId", id),
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "listCameras failed: ${e.message}")
|
||||
}
|
||||
return arr.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Open camera [index] and start streaming RGB frames to Python.
|
||||
* Blocks until the capture session is configured (or fails/times out).
|
||||
*
|
||||
* Returns false — without throwing across the JNI boundary — when the
|
||||
* CAMERA permission is missing, the index is out of range, or the
|
||||
* device/session fails to configure. Closes any previously-open camera
|
||||
* first (one active at a time).
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
@JvmStatic
|
||||
fun startCamera(index: Int, width: Int, height: Int): Boolean {
|
||||
synchronized(lock) {
|
||||
closeLocked()
|
||||
|
||||
val ctx = appContext ?: run {
|
||||
Log.w(TAG, "startCamera: context not bound")
|
||||
return false
|
||||
}
|
||||
if (ctx.checkSelfPermission(Manifest.permission.CAMERA)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
Log.w(TAG, "startCamera: CAMERA permission not granted")
|
||||
return false
|
||||
}
|
||||
|
||||
val mgr = ctx.getSystemService(Context.CAMERA_SERVICE) as CameraManager
|
||||
val ids = try {
|
||||
mgr.cameraIdList
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "startCamera: cameraIdList failed: ${e.message}")
|
||||
return false
|
||||
}
|
||||
if (index < 0 || index >= ids.size) {
|
||||
Log.w(TAG, "startCamera: index $index out of range (${ids.size} cameras)")
|
||||
return false
|
||||
}
|
||||
val cameraId = ids[index]
|
||||
val size = chooseSize(mgr, cameraId, width, height) ?: run {
|
||||
Log.w(TAG, "startCamera: no YUV output sizes for camera $index")
|
||||
return false
|
||||
}
|
||||
|
||||
val reader = ImageReader.newInstance(
|
||||
size.width, size.height, ImageFormat.YUV_420_888, MAX_IMAGES,
|
||||
)
|
||||
// Size the conversion buffers once for this session.
|
||||
rgbBuffer = ByteArray(size.width * size.height * BYTES_PER_RGB)
|
||||
yBuf = null; uBuf = null; vBuf = null
|
||||
nextFrameNanos = SystemClock.elapsedRealtimeNanos()
|
||||
reader.setOnImageAvailableListener({ r -> onFrame(r) }, camHandler)
|
||||
|
||||
return try {
|
||||
runBlocking {
|
||||
withTimeout(OPEN_TIMEOUT_MS) {
|
||||
// Publish each resource to its field as soon as it exists so
|
||||
// closeLocked() (in the catch) can release it if a LATER step
|
||||
// throws. Assigning only after setRepeatingRequest succeeds
|
||||
// would orphan the opened CameraDevice on a createSession /
|
||||
// setRepeatingRequest failure (camera stuck on; subsequent
|
||||
// opens fail with CAMERA_IN_USE).
|
||||
imageReader = reader
|
||||
val device = openCamera(mgr, cameraId)
|
||||
cameraDevice = device
|
||||
val session = createSession(device, reader.surface)
|
||||
captureSession = session
|
||||
val request = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
|
||||
.apply { addTarget(reader.surface) }
|
||||
.build()
|
||||
session.setRepeatingRequest(request, null, camHandler)
|
||||
activeIndex = index
|
||||
running = true
|
||||
Log.i(TAG, "Camera $index opened (${size.width}x${size.height} @ ${TARGET_FPS}fps)")
|
||||
true
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "startCamera($index) failed: ${e.message}")
|
||||
// imageReader/cameraDevice/captureSession are now whatever got
|
||||
// assigned before the failure — closeLocked releases each exactly
|
||||
// once (idempotent, runCatching-wrapped).
|
||||
closeLocked()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop streaming and release the camera. Idempotent; safe if not started. */
|
||||
@JvmStatic
|
||||
fun stopCamera() {
|
||||
synchronized(lock) { closeLocked() }
|
||||
Log.i(TAG, "Camera stopped")
|
||||
}
|
||||
|
||||
// ── internals ────────────────────────────────────────────────────────
|
||||
|
||||
private fun facingOf(mgr: CameraManager, id: String): String =
|
||||
when (mgr.getCameraCharacteristics(id).get(CameraCharacteristics.LENS_FACING)) {
|
||||
CameraCharacteristics.LENS_FACING_FRONT -> "front"
|
||||
CameraCharacteristics.LENS_FACING_BACK -> "back"
|
||||
CameraCharacteristics.LENS_FACING_EXTERNAL -> "external"
|
||||
else -> "unknown"
|
||||
}
|
||||
|
||||
/** Pick the supported YUV size closest in area to the request (or the
|
||||
* balanced default for `auto`/0). */
|
||||
private fun chooseSize(mgr: CameraManager, cameraId: String, reqW: Int, reqH: Int): Size? {
|
||||
val map = mgr.getCameraCharacteristics(cameraId)
|
||||
.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) ?: return null
|
||||
val sizes = map.getOutputSizes(ImageFormat.YUV_420_888)
|
||||
if (sizes == null || sizes.isEmpty()) return null
|
||||
val targetArea = (if (reqW > 0) reqW else DEFAULT_W).toLong() *
|
||||
(if (reqH > 0) reqH else DEFAULT_H)
|
||||
return sizes.minByOrNull { kotlin.math.abs(it.width.toLong() * it.height - targetArea) }
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private suspend fun openCamera(mgr: CameraManager, cameraId: String): CameraDevice =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
mgr.openCamera(cameraId, object : CameraDevice.StateCallback() {
|
||||
override fun onOpened(device: CameraDevice) {
|
||||
if (cont.isActive) cont.resume(device) else device.close()
|
||||
}
|
||||
|
||||
override fun onDisconnected(device: CameraDevice) {
|
||||
device.close()
|
||||
if (cont.isActive) cont.resumeWithException(IllegalStateException("camera disconnected"))
|
||||
}
|
||||
|
||||
override fun onError(device: CameraDevice, error: Int) {
|
||||
device.close()
|
||||
if (cont.isActive) cont.resumeWithException(IllegalStateException("camera error $error"))
|
||||
}
|
||||
}, camHandler)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private suspend fun createSession(device: CameraDevice, surface: Surface): CameraCaptureSession =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
// createCaptureSession(List, callback, handler) is deprecated at
|
||||
// API 30 but is the correct API down to minSdk 24 (the
|
||||
// SessionConfiguration overload is API 28+).
|
||||
device.createCaptureSession(
|
||||
listOf(surface),
|
||||
object : CameraCaptureSession.StateCallback() {
|
||||
override fun onConfigured(session: CameraCaptureSession) {
|
||||
if (cont.isActive) cont.resume(session)
|
||||
}
|
||||
|
||||
override fun onConfigureFailed(session: CameraCaptureSession) {
|
||||
if (cont.isActive) cont.resumeWithException(IllegalStateException("session configure failed"))
|
||||
}
|
||||
},
|
||||
camHandler,
|
||||
)
|
||||
}
|
||||
|
||||
/** ImageReader callback — paced, converts YUV→RGB, pushes to Python. */
|
||||
private fun onFrame(reader: ImageReader) {
|
||||
if (!running) {
|
||||
runCatching { reader.acquireLatestImage()?.close() }
|
||||
return
|
||||
}
|
||||
val now = SystemClock.elapsedRealtimeNanos()
|
||||
if (now < nextFrameNanos) {
|
||||
runCatching { reader.acquireLatestImage()?.close() }
|
||||
return
|
||||
}
|
||||
val image = runCatching { reader.acquireLatestImage() }.getOrNull() ?: return
|
||||
try {
|
||||
val w = image.width
|
||||
val h = image.height
|
||||
val out = ensureRgbBuffer(w * h * BYTES_PER_RGB)
|
||||
yuv420ToRgb(image, out, w, h)
|
||||
pushFrame(out, w, h)
|
||||
nextFrameNanos += frameIntervalNanos
|
||||
if (now - nextFrameNanos > frameIntervalNanos * 4) {
|
||||
nextFrameNanos = now + frameIntervalNanos
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "frame processing error: ${e.message}")
|
||||
} finally {
|
||||
runCatching { image.close() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureRgbBuffer(size: Int): ByteArray {
|
||||
val buf = rgbBuffer
|
||||
if (buf != null && buf.size == size) return buf
|
||||
return ByteArray(size).also { rgbBuffer = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stride-aware YUV_420_888 → packed RGB (3 bytes/px) using BT.601
|
||||
* fixed-point coefficients. Handles both planar and semi-planar
|
||||
* (NV21-like, pixelStride 2) chroma layouts via the plane strides.
|
||||
*/
|
||||
private fun yuv420ToRgb(image: Image, out: ByteArray, width: Int, height: Int) {
|
||||
val planes = image.planes
|
||||
val yPlane = planes[0]
|
||||
val uPlane = planes[1]
|
||||
val vPlane = planes[2]
|
||||
|
||||
val yRowStride = yPlane.rowStride
|
||||
val yPixStride = yPlane.pixelStride
|
||||
val uRowStride = uPlane.rowStride
|
||||
val uPixStride = uPlane.pixelStride
|
||||
val vRowStride = vPlane.rowStride
|
||||
val vPixStride = vPlane.pixelStride
|
||||
|
||||
// Copy each plane to a reusable array for fast indexed access
|
||||
// (ByteBuffer absolute-get per pixel is far slower).
|
||||
val yByteBuf = yPlane.buffer
|
||||
val uByteBuf = uPlane.buffer
|
||||
val vByteBuf = vPlane.buffer
|
||||
val yArr = ensurePlane(yBuf, yByteBuf.remaining()).also { yBuf = it }
|
||||
val uArr = ensurePlane(uBuf, uByteBuf.remaining()).also { uBuf = it }
|
||||
val vArr = ensurePlane(vBuf, vByteBuf.remaining()).also { vBuf = it }
|
||||
yByteBuf.get(yArr, 0, yArr.size)
|
||||
uByteBuf.get(uArr, 0, uArr.size)
|
||||
vByteBuf.get(vArr, 0, vArr.size)
|
||||
|
||||
var o = 0
|
||||
for (row in 0 until height) {
|
||||
val yRowBase = row * yRowStride
|
||||
val uvRow = row shr 1
|
||||
val uRowBase = uvRow * uRowStride
|
||||
val vRowBase = uvRow * vRowStride
|
||||
for (col in 0 until width) {
|
||||
val y = (yArr[yRowBase + col * yPixStride].toInt() and 0xFF)
|
||||
val uvCol = col shr 1
|
||||
val u = (uArr[uRowBase + uvCol * uPixStride].toInt() and 0xFF) - 128
|
||||
val v = (vArr[vRowBase + uvCol * vPixStride].toInt() and 0xFF) - 128
|
||||
// BT.601 full-range, fixed-point (<<16).
|
||||
var r = y + ((91881 * v) shr 16)
|
||||
var g = y - ((22554 * u + 46802 * v) shr 16)
|
||||
var b = y + ((116130 * u) shr 16)
|
||||
if (r < 0) r = 0 else if (r > 255) r = 255
|
||||
if (g < 0) g = 0 else if (g > 255) g = 255
|
||||
if (b < 0) b = 0 else if (b > 255) b = 255
|
||||
out[o++] = r.toByte()
|
||||
out[o++] = g.toByte()
|
||||
out[o++] = b.toByte()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Return [cached] if it already fits [n] bytes, else a fresh array. */
|
||||
private fun ensurePlane(cached: ByteArray?, n: Int): ByteArray =
|
||||
if (cached != null && cached.size == n) cached else ByteArray(n)
|
||||
|
||||
private fun pushFrame(rgb: ByteArray, width: Int, height: Int) {
|
||||
val module = engineModule ?: runCatching {
|
||||
Python.getInstance().getModule(ENGINE_MODULE)
|
||||
}.getOrNull()?.also { engineModule = it } ?: return
|
||||
try {
|
||||
module.callAttr("push_frame", rgb, width, height)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "push_frame failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** Tear down the active session. Caller holds [lock]. */
|
||||
private fun closeLocked() {
|
||||
running = false
|
||||
activeIndex = -1
|
||||
runCatching { imageReader?.setOnImageAvailableListener(null, null) }
|
||||
runCatching { captureSession?.stopRepeating() }
|
||||
runCatching { captureSession?.close() }
|
||||
captureSession = null
|
||||
runCatching { cameraDevice?.close() }
|
||||
cameraDevice = null
|
||||
runCatching { imageReader?.close() }
|
||||
imageReader = null
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,12 @@ import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.Manifest
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.media.projection.MediaProjection
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Build
|
||||
@@ -15,6 +18,7 @@ import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -26,7 +30,13 @@ import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Foreground service that runs the Python LedGrab server and captures
|
||||
* the screen via MediaProjection.
|
||||
* the screen via MediaProjection or root screenrecord.
|
||||
*
|
||||
* On Android 14+ the foreground-service "type" must match the work
|
||||
* being done. We promote the service with the correct type (mediaProjection
|
||||
* for the consent path, specialUse for the root path) instead of
|
||||
* declaring a single fixed type in the manifest — the manifest now
|
||||
* declares the *union* so promotion at runtime is permitted.
|
||||
*/
|
||||
class CaptureService : Service() {
|
||||
|
||||
@@ -77,6 +87,7 @@ class CaptureService : Service() {
|
||||
private var bridge: PythonBridge? = null
|
||||
private var screenCapture: ScreenCapture? = null
|
||||
private var rootCapture: RootScreenrecord? = null
|
||||
private var audioCapture: AudioCapture? = null
|
||||
private var mediaProjection: MediaProjection? = null
|
||||
|
||||
// Service-scoped coroutine scope for the root-capture watchdog.
|
||||
@@ -92,15 +103,83 @@ class CaptureService : Service() {
|
||||
}
|
||||
|
||||
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 useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
|
||||
|
||||
// CRITICAL (Android 14+): for the MediaProjection path, validate the
|
||||
// projection token BEFORE promoting to a foreground service with the
|
||||
// mediaProjection FGS type. On service recreation (system redelivery
|
||||
// or a stale relaunch) the consent token is gone — promoting first and
|
||||
// then discovering the dead token causes a spurious foreground-service
|
||||
// start + immediate stop, which on strict OEMs flickers the
|
||||
// notification or trips a stopSelf loop. Bail out cleanly here, before
|
||||
// startForeground, when the MediaProjection consent data is missing.
|
||||
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "—"
|
||||
val url = "http://$localIp:$SERVER_PORT"
|
||||
|
||||
val mediaProjectionResultData: Intent? =
|
||||
if (!useRoot) extractProjectionResultData(intent) else null
|
||||
if (!useRoot && (intent == null || mediaProjectionResultData == null)) {
|
||||
// MediaProjection mode can't recover from a redelivery —
|
||||
// the consent token in the original intent is single-use.
|
||||
//
|
||||
// We were launched via startForegroundService(), so the OS REQUIRES
|
||||
// a startForeground() within ~5s even on this immediate-stop path,
|
||||
// or it raises the fatal ForegroundServiceDidNotStartInTimeException.
|
||||
// Promote with a benign SPECIAL_USE type (NOT mediaProjection — we
|
||||
// have no valid consent token, and requesting that type without an
|
||||
// active projection is exactly what we're avoiding) just long enough
|
||||
// to satisfy the contract, then stop.
|
||||
Log.w(TAG, "MediaProjection start without a valid consent token — stopping")
|
||||
runCatching {
|
||||
val bailType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(url), bailType)
|
||||
}.onFailure { Log.w(TAG, "Bail-path startForeground failed: ${it.message}") }
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
// startForeground must be called IMMEDIATELY after the token check —
|
||||
// before any heavier work like getMediaProjection(). The service type
|
||||
// must match the work; pass it explicitly via ServiceCompat so we stay
|
||||
// compatible back to API 24. The MEDIA_PROJECTION type is only used
|
||||
// here once resultData is confirmed non-null (checked above).
|
||||
try {
|
||||
startForeground(NOTIFICATION_ID, buildNotification(url))
|
||||
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
var t = if (useRoot) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
} else {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
||||
}
|
||||
// On-demand webcam capture opens the camera from this service.
|
||||
// To retain camera access once the app is backgrounded (the
|
||||
// always-on ambient-lighting case), API 34+ requires the camera
|
||||
// FGS type. Add it ONLY when CAMERA is already granted — promoting
|
||||
// with the camera type without the runtime permission throws and
|
||||
// would kill the whole service on the (common) camera-less or
|
||||
// not-yet-granted box. If CAMERA is granted later, it takes effect
|
||||
// on the next Start (matches the audio/permission UX).
|
||||
if (checkSelfPermission(Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
t = t or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
|
||||
}
|
||||
t
|
||||
} else {
|
||||
0
|
||||
}
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
NOTIFICATION_ID,
|
||||
buildNotification(url),
|
||||
type,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Most common cause: missing foregroundServiceType permission
|
||||
// or denied POST_NOTIFICATIONS on API 34+.
|
||||
// or denied POST_NOTIFICATIONS on API 33+.
|
||||
Log.e(TAG, "startForeground failed — service cannot run", e)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
@@ -109,22 +188,13 @@ class CaptureService : Service() {
|
||||
// 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)
|
||||
// mediaProjectionResultData is guaranteed non-null here — the
|
||||
// token was validated before startForeground above.
|
||||
startMediaProjectionCapture(intent!!, mediaProjectionResultData!!, url)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start capture", e)
|
||||
@@ -140,10 +210,13 @@ class CaptureService : Service() {
|
||||
return if (useRoot) START_REDELIVER_INTENT else START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun apiKey(): String? =
|
||||
(application as? LedGrabApp)?.apiKeyManager?.apiKey
|
||||
|
||||
private fun startRootCapture(url: String) {
|
||||
val newBridge = PythonBridge(this).also { b ->
|
||||
b.configureRootCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
|
||||
b.startServer(SERVER_PORT)
|
||||
b.startServer(SERVER_PORT, apiKey())
|
||||
}
|
||||
bridge = newBridge
|
||||
|
||||
@@ -167,12 +240,21 @@ class CaptureService : Service() {
|
||||
* Replace the active root pipeline with a fresh instance, reusing
|
||||
* the existing Python bridge (no server restart). Returns true if
|
||||
* the new pipeline launched, false otherwise.
|
||||
*
|
||||
* Synchronized so a concurrent onDestroy() either (a) sees the old
|
||||
* instance and stops it then null-out, or (b) sees the new instance
|
||||
* and stops it. There is no window where a fresh instance can be
|
||||
* orphaned with no one holding a reference to it.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun restartRootPipeline(): Boolean {
|
||||
val currentBridge = bridge ?: return false
|
||||
val old = rootCapture
|
||||
// Tear down the old instance first so we don't run two
|
||||
// screenrecord processes simultaneously fighting for the GPU.
|
||||
rootCapture?.let { old ->
|
||||
rootCapture = null
|
||||
runCatching { old?.stop() }
|
||||
runCatching { old.stop() }
|
||||
}
|
||||
|
||||
val next = RootScreenrecord(
|
||||
bridge = currentBridge,
|
||||
@@ -180,11 +262,21 @@ class CaptureService : Service() {
|
||||
height = CAPTURE_HEIGHT,
|
||||
fps = CAPTURE_FPS,
|
||||
)
|
||||
// Publish BEFORE start() — if onDestroy fires after this
|
||||
// assignment but before start() completes, the field is non-null
|
||||
// and onDestroy will stop() it properly. start() is idempotent
|
||||
// enough (running=true, then resource construction) that being
|
||||
// raced by stop() at most produces a brief partial-init that
|
||||
// the next stop() call cleans up.
|
||||
rootCapture = next
|
||||
if (!next.start()) {
|
||||
Log.e(TAG, "Root capture failed to restart")
|
||||
// start() already called stop() on itself on the failure
|
||||
// path — but null out the field so the watchdog/onDestroy
|
||||
// don't try to stop it again.
|
||||
rootCapture = null
|
||||
return false
|
||||
}
|
||||
rootCapture = next
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -212,7 +304,7 @@ class CaptureService : Service() {
|
||||
"Root capture stalled (no new frames in ${WATCHDOG_CHECK_MS}ms); " +
|
||||
"restart attempt $restartAttempts/$WATCHDOG_MAX_RESTARTS",
|
||||
)
|
||||
if (restartAttempts > WATCHDOG_MAX_RESTARTS) {
|
||||
if (restartAttempts >= WATCHDOG_MAX_RESTARTS) {
|
||||
Log.e(TAG, "Watchdog gave up after $WATCHDOG_MAX_RESTARTS restarts")
|
||||
stopSelf()
|
||||
return@launch
|
||||
@@ -231,21 +323,25 @@ class CaptureService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun startMediaProjectionCapture(intent: Intent, url: String) {
|
||||
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
|
||||
/**
|
||||
* Extract the single-use MediaProjection consent token from the start
|
||||
* intent, or null if the intent is missing/redelivered without it.
|
||||
* Called BEFORE startForeground so the mediaProjection FGS type is only
|
||||
* ever requested when a valid token is present (see onStartCommand).
|
||||
*/
|
||||
private fun extractProjectionResultData(intent: Intent?): Intent? {
|
||||
if (intent == null) return null
|
||||
@Suppress("DEPRECATION")
|
||||
val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
return 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
|
||||
}
|
||||
|
||||
private fun startMediaProjectionCapture(intent: Intent, resultData: Intent, url: String) {
|
||||
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
|
||||
|
||||
val projectionManager =
|
||||
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
val projection = projectionManager.getMediaProjection(resultCode, resultData)
|
||||
@@ -263,7 +359,6 @@ class CaptureService : Service() {
|
||||
val bounds = windowMetrics.bounds
|
||||
widthPixels = bounds.width()
|
||||
heightPixels = bounds.height()
|
||||
// densityDpi is still needed for VirtualDisplay; read from resources.
|
||||
densityDpi = resources.displayMetrics.densityDpi
|
||||
}
|
||||
} else {
|
||||
@@ -276,7 +371,7 @@ class CaptureService : Service() {
|
||||
|
||||
val newBridge = PythonBridge(this).also { b ->
|
||||
b.configureCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
|
||||
b.startServer(SERVER_PORT)
|
||||
b.startServer(SERVER_PORT, apiKey())
|
||||
}
|
||||
bridge = newBridge
|
||||
|
||||
@@ -293,6 +388,25 @@ class CaptureService : Service() {
|
||||
onProjectionStopped = { stopSelf() },
|
||||
).also { it.start() }
|
||||
|
||||
// Reuse the same projection to capture system playback audio so
|
||||
// audio-reactive lighting works on-device (API 29+, RECORD_AUDIO
|
||||
// granted). Best-effort: screen capture and the server keep running
|
||||
// if audio is unavailable. Started AFTER ScreenCapture so the
|
||||
// projection's callback is already registered.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
checkSelfPermission(Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
audioCapture = AudioCapture(projection, newBridge).also { ac ->
|
||||
if (!ac.start()) {
|
||||
Log.i(TAG, "Playback audio capture unavailable — continuing without audio")
|
||||
audioCapture = null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "RECORD_AUDIO not granted or API < 29 — audio-reactive capture disabled")
|
||||
}
|
||||
|
||||
Log.i(TAG, "LedGrab service started (MediaProjection) — web UI at $url")
|
||||
}
|
||||
|
||||
@@ -306,6 +420,10 @@ class CaptureService : Service() {
|
||||
screenCapture?.stop()
|
||||
screenCapture = null
|
||||
|
||||
// Stop audio before the server: stop() calls bridge.shutdownAudio().
|
||||
audioCapture?.stop()
|
||||
audioCapture = null
|
||||
|
||||
rootCapture?.stop()
|
||||
rootCapture = null
|
||||
|
||||
@@ -323,10 +441,10 @@ class CaptureService : Service() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"LedGrab Screen Capture",
|
||||
getString(R.string.notification_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Shows while LedGrab is capturing the screen"
|
||||
description = getString(R.string.notification_channel_description)
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
@@ -343,9 +461,14 @@ class CaptureService : Service() {
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("LedGrab Running")
|
||||
.setContentText("Web UI: $url")
|
||||
.setSmallIcon(R.drawable.ic_launcher)
|
||||
.setContentTitle(getString(R.string.notification_title))
|
||||
.setContentText(getString(R.string.notification_text, url))
|
||||
// ic_notification is a monochrome 24dp vector — status-bar
|
||||
// icons must be white-on-transparent or they render as a
|
||||
// gray blob on Android 5+.
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setColor(0xFF64FFDA.toInt())
|
||||
.setColorized(true)
|
||||
.setContentIntent(tapIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.app.AppOpsManager
|
||||
import android.app.usage.UsageEvents
|
||||
import android.app.usage.UsageStatsManager
|
||||
import android.content.Context
|
||||
import android.content.pm.LauncherApps
|
||||
import android.os.Build
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Foreground-app + installed-app bridge exposed to the Python server via Chaquopy.
|
||||
*
|
||||
* Backs the Android implementation of the "Application" automation rule
|
||||
* (foreground app -> activate scene). Desktop detects the foreground process via
|
||||
* Win32 ctypes in ``platform_detector.py``; Android has no such API, so this
|
||||
* bridge wraps two in-platform services into synchronous calls a Python thread
|
||||
* can invoke (Chaquopy proxy threads are real OS threads):
|
||||
*
|
||||
* - [getForegroundPackage] via [UsageStatsManager] (needs PACKAGE_USAGE_STATS,
|
||||
* a special-access permission granted from Settings — see MainActivity).
|
||||
* - [listLaunchableApps] via [LauncherApps] for the automation editor's app
|
||||
* picker (no QUERY_ALL_PACKAGES needed — getActivityList is the sanctioned
|
||||
* launchable-app enumeration API).
|
||||
* - [hasUsageAccess] so the server / UI can detect the missing grant.
|
||||
*
|
||||
* Detection only ever string-compares the foreground *package name*, so no label
|
||||
* resolution / package visibility is required at match time.
|
||||
*
|
||||
* Python callers access the singleton via
|
||||
* `jclass("com.ledgrab.android.ForegroundAppBridge").INSTANCE` — see
|
||||
* `server/src/ledgrab/core/automations/platform_detector.py`.
|
||||
*/
|
||||
object ForegroundAppBridge {
|
||||
private const val TAG = "ForegroundAppBridge"
|
||||
|
||||
// Trailing window for queryEvents. queryEvents reports discrete foreground
|
||||
// transitions (not "current app"), and events can lag a few seconds, so we
|
||||
// look back far enough to reliably catch the latest MOVE_TO_FOREGROUND while
|
||||
// staying recent enough not to report a stale app on the ~1s automation tick.
|
||||
private const val WINDOW_MS = 10_000L
|
||||
|
||||
@Volatile private var appContext: Context? = null
|
||||
|
||||
/** Called once from [LedGrabApp.onCreate] to bind the application context. */
|
||||
@JvmStatic
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Package name of the most recently foregrounded app, or null when none is
|
||||
* found in the trailing window, Usage Access is not granted, or on any error.
|
||||
* Never throws across the JNI boundary.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getForegroundPackage(): String? {
|
||||
val ctx = appContext ?: run {
|
||||
Log.w(TAG, "getForegroundPackage: context not bound (init not called)")
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
val usm = ctx.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager
|
||||
?: return null
|
||||
val end = System.currentTimeMillis()
|
||||
val events = usm.queryEvents(end - WINDOW_MS, end)
|
||||
val event = UsageEvents.Event()
|
||||
var latestPkg: String? = null
|
||||
var latestTs = Long.MIN_VALUE
|
||||
while (events.hasNextEvent()) {
|
||||
events.getNextEvent(event)
|
||||
// ACTIVITY_RESUMED (API 29+) shares the value of the legacy
|
||||
// MOVE_TO_FOREGROUND constant, so the single check covers both.
|
||||
// >= (not >) so that on an exact-timestamp tie the later-iterated
|
||||
// event wins — events arrive chronologically, so that is the most
|
||||
// recent foreground transition.
|
||||
if (event.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND &&
|
||||
event.timeStamp >= latestTs
|
||||
) {
|
||||
latestTs = event.timeStamp
|
||||
latestPkg = event.packageName
|
||||
}
|
||||
}
|
||||
latestPkg
|
||||
} catch (e: Exception) {
|
||||
// SecurityException when access is missing, plus any service error.
|
||||
Log.w(TAG, "getForegroundPackage failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the user has granted Usage Access (PACKAGE_USAGE_STATS) to this app. */
|
||||
@JvmStatic
|
||||
fun hasUsageAccess(): Boolean {
|
||||
val ctx = appContext ?: return false
|
||||
return try {
|
||||
val appOps = ctx.getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager
|
||||
?: return false
|
||||
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
appOps.unsafeCheckOpNoThrow(
|
||||
AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), ctx.packageName,
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
appOps.checkOpNoThrow(
|
||||
AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), ctx.packageName,
|
||||
)
|
||||
}
|
||||
mode == AppOpsManager.MODE_ALLOWED
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "hasUsageAccess failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launchable apps as a JSON array string the Python server parses:
|
||||
* `[{"package":"com.netflix.mediaclient","label":"Netflix"}, ...]`
|
||||
*
|
||||
* Uses [LauncherApps.getActivityList] (launcher + leanback launchables) —
|
||||
* no QUERY_ALL_PACKAGES. De-duplicated by package, sorted by label.
|
||||
* Returns `[]` on any error.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun listLaunchableApps(): String {
|
||||
val arr = JSONArray()
|
||||
val ctx = appContext ?: run {
|
||||
Log.w(TAG, "listLaunchableApps: context not bound (init not called)")
|
||||
return arr.toString()
|
||||
}
|
||||
try {
|
||||
val launcher = ctx.getSystemService(Context.LAUNCHER_APPS_SERVICE) as? LauncherApps
|
||||
?: return arr.toString()
|
||||
val seen = HashSet<String>()
|
||||
val items = ArrayList<Pair<String, String>>()
|
||||
for (info in launcher.getActivityList(null, Process.myUserHandle())) {
|
||||
val pkg = info.applicationInfo?.packageName ?: continue
|
||||
if (!seen.add(pkg)) continue
|
||||
val label = info.label?.toString().takeUnless { it.isNullOrBlank() } ?: pkg
|
||||
items.add(pkg to label)
|
||||
}
|
||||
items.sortBy { it.second.lowercase() }
|
||||
for ((pkg, label) in items) {
|
||||
arr.put(JSONObject().put("package", pkg).put("label", label))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "listLaunchableApps failed: ${e.message}")
|
||||
}
|
||||
return arr.toString()
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,13 @@ class LedGrabApp : Application() {
|
||||
var initError: Throwable? = null
|
||||
private set
|
||||
|
||||
/** Lazily-initialized API-key manager (see [ApiKeyManager]). */
|
||||
val apiKeyManager: ApiKeyManager by lazy { ApiKeyManager(this) }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
installCrashLogger()
|
||||
pruneOldCrashLogs()
|
||||
try {
|
||||
if (!Python.isStarted()) {
|
||||
Python.start(AndroidPlatform(this))
|
||||
@@ -47,6 +51,22 @@ class LedGrabApp : Application() {
|
||||
// Bind application context for the BLE bridge so Python can
|
||||
// scan and connect to BLE LED controllers.
|
||||
BleBridge.init(this)
|
||||
// Bind application context for the camera bridge so Python can
|
||||
// enumerate cameras and open them on demand (webcam capture).
|
||||
CameraBridge.init(this)
|
||||
// Bind application context for the foreground-app bridge so Python can
|
||||
// detect the foreground app (Application automation rule) and list
|
||||
// launchable apps for the editor's picker.
|
||||
ForegroundAppBridge.init(this)
|
||||
|
||||
// Pre-warm the API key on a background thread. First-launch
|
||||
// generation does a SharedPreferences.commit() (synchronous
|
||||
// disk write — 10-50 ms on slow TV-box flash), which would
|
||||
// hit the Main thread otherwise when MainActivity / CaptureService
|
||||
// reads it. Doing it here makes subsequent reads memory-only.
|
||||
Thread({
|
||||
runCatching { apiKeyManager.apiKey }
|
||||
}, "ledgrab-apikey-warmup").apply { isDaemon = true }.start()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +97,24 @@ class LedGrabApp : Application() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the most recent [MAX_CRASH_LOGS] crash files so a
|
||||
* long-lived install doesn't slowly fill its private storage with
|
||||
* historical traces. Cheap on every launch — listFiles is O(n)
|
||||
* but n is tiny by construction.
|
||||
*/
|
||||
private fun pruneOldCrashLogs() {
|
||||
val logs = filesDir.listFiles { f ->
|
||||
f.isFile && f.name.startsWith("crash-") && f.name.endsWith(".log")
|
||||
} ?: return
|
||||
if (logs.size <= MAX_CRASH_LOGS) return
|
||||
logs.sortedByDescending { it.lastModified() }
|
||||
.drop(MAX_CRASH_LOGS)
|
||||
.forEach { runCatching { it.delete() } }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LedGrabApp"
|
||||
private const val MAX_CRASH_LOGS = 10
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.app.Notification
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import android.util.Log
|
||||
import com.chaquo.python.Python
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.RejectedExecutionException
|
||||
|
||||
/**
|
||||
* Captures posted OS notifications and forwards the posting app's display
|
||||
* label to the Python notification pipeline, where the existing
|
||||
* `NotificationColorStripSource` fires its one-shot LED effect.
|
||||
*
|
||||
* Direction is Kotlin -> Python via the process-global Chaquopy instance
|
||||
* (NOT a per-[CaptureService] [PythonBridge]): `system_server` binds this
|
||||
* service independently of [CaptureService], so it resolves Python itself.
|
||||
* The Python receiver (`os_notification_listener.push_notification`) is a
|
||||
* no-op whenever the server/listener isn't running, so a notification
|
||||
* arriving before — or after — a capture session is safely ignored.
|
||||
*/
|
||||
class LedGrabNotificationListener : NotificationListenerService() {
|
||||
|
||||
// Serial executor: the Python receiver does a (non-concurrency-safe) history
|
||||
// disk write and may play a sound, so pushes must not overlap. Off the main
|
||||
// looper to keep the system service responsive.
|
||||
//
|
||||
// Tied to the listener-connection lifecycle (onListenerConnected /
|
||||
// onListenerDisconnected), NOT onDestroy: this is a system-rebindable
|
||||
// service, so it can be connected/disconnected multiple times across a
|
||||
// single onCreate..onDestroy span. Managing the executor here — combined
|
||||
// with the runCatching guard at the submit site — keeps a notification
|
||||
// that races teardown from triggering RejectedExecutionException on a
|
||||
// shut-down executor. @Volatile so the connect/disconnect callbacks (which
|
||||
// may run on a different thread than onNotificationPosted) publish safely.
|
||||
@Volatile private var pushExecutor: ExecutorService? = null
|
||||
|
||||
// Guards executor creation so the lazy submit-site fallback and
|
||||
// onListenerConnected can't race two executors into existence.
|
||||
private val executorLock = Any()
|
||||
|
||||
// Tracks whether the listener is currently connected. ensureExecutor() only
|
||||
// CREATES a new executor while connected — otherwise a notification racing
|
||||
// onListenerDisconnected (which nulls pushExecutor) would spin up a fresh
|
||||
// executor that nothing reaps until the next disconnect cycle (a thread leak).
|
||||
@Volatile private var connected: Boolean = false
|
||||
|
||||
// packageName -> resolved human-readable label. Matches the app_name the
|
||||
// Windows/Linux backends pass, so per-app colors/filters keep working.
|
||||
// Naturally bounded by the number of notification-posting apps (tens) and
|
||||
// cleared with the process — no eviction needed.
|
||||
private val labelCache = ConcurrentHashMap<String, String>()
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification?) {
|
||||
val notification = sbn ?: return
|
||||
|
||||
// The Python server (and thus the listener) only exists during a capture
|
||||
// session. isRunning is a coarse early-out — the authoritative gate is the
|
||||
// Python receiver's None-check — but it avoids needless JNI churn here.
|
||||
if (!CaptureService.isRunning) return
|
||||
|
||||
// Filter notifications that should never drive an effect:
|
||||
// - ongoing (media transport, downloads): not user-facing "alerts"
|
||||
// - group summaries: duplicate their child notifications
|
||||
// - our own foreground-service notification: would self-trigger
|
||||
if (notification.isOngoing) return
|
||||
if ((notification.notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0) return
|
||||
if (notification.packageName == packageName) return
|
||||
|
||||
val label = resolveAppLabel(notification.packageName)
|
||||
|
||||
// Obtain (creating if needed) the executor. onListenerConnected normally
|
||||
// creates it, but that callback is not reliably invoked on every
|
||||
// OEM/version (re)bind, and a notification can arrive before it fires —
|
||||
// lazily creating here keeps a missing/late onListenerConnected from
|
||||
// permanently disabling notification forwarding. A late submit onto an
|
||||
// executor that onListenerDisconnected is shutting down throws
|
||||
// RejectedExecutionException — guard with runCatching so a notification
|
||||
// racing teardown can never crash this system-bound service.
|
||||
val executor = ensureExecutor() ?: run {
|
||||
Log.d(TAG, "no executor (listener disconnected) — skipping push")
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
executor.execute {
|
||||
try {
|
||||
Python.getInstance()
|
||||
.getModule(PY_MODULE)
|
||||
.callAttr("push_notification", label)
|
||||
} catch (t: Throwable) {
|
||||
// Never crash a system-bound service. Python.getInstance() throws
|
||||
// IllegalStateException if Python.start() hasn't run (e.g. the
|
||||
// service was bound at boot before the app process initialized).
|
||||
// Log at debug — the label is potentially sensitive on a shared TV.
|
||||
Log.d(TAG, "push_notification failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
}.onFailure { e ->
|
||||
if (e is RejectedExecutionException) {
|
||||
Log.d(TAG, "push rejected — listener disconnecting")
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve (and cache) a package's human-readable label; fall back to the package name. */
|
||||
private fun resolveAppLabel(pkg: String): String {
|
||||
labelCache[pkg]?.let { return it }
|
||||
// Only cache SUCCESSFUL resolutions. Caching the package-name fallback
|
||||
// would permanently pin a wrong label if the PackageManager lookup
|
||||
// failed transiently (e.g. the app was mid-install / still updating).
|
||||
val resolved = runCatching {
|
||||
val info = packageManager.getApplicationInfo(pkg, 0)
|
||||
packageManager.getApplicationLabel(info).toString()
|
||||
}.getOrNull()
|
||||
if (resolved != null) {
|
||||
labelCache[pkg] = resolved
|
||||
return resolved
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the push executor, creating it under [executorLock] if absent AND
|
||||
* the listener is connected. Returns null when disconnected so a notification
|
||||
* racing teardown neither submits onto a shutting-down executor nor spins up
|
||||
* a stray one. Safe against a concurrent onListenerConnected/onNotificationPosted
|
||||
* race (single executor) and against a missing onListenerConnected callback.
|
||||
*/
|
||||
private fun ensureExecutor(): ExecutorService? {
|
||||
pushExecutor?.let { return it }
|
||||
synchronized(executorLock) {
|
||||
if (!connected) return null
|
||||
return pushExecutor ?: Executors.newSingleThreadExecutor().also { pushExecutor = it }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListenerConnected() {
|
||||
Log.i(TAG, "Notification listener connected")
|
||||
// Spin up the push executor on connect. The system can disconnect and
|
||||
// later reconnect this service without destroying it, so own the
|
||||
// executor here rather than in onCreate/onDestroy. onNotificationPosted
|
||||
// also lazily creates it (via ensureExecutor) in case this callback is
|
||||
// late or skipped on some ROMs.
|
||||
connected = true
|
||||
ensureExecutor()
|
||||
}
|
||||
|
||||
override fun onListenerDisconnected() {
|
||||
Log.i(TAG, "Notification listener disconnected")
|
||||
// Mark disconnected BEFORE nulling the executor so a racing ensureExecutor
|
||||
// sees !connected and skips creating a replacement. Tear the executor
|
||||
// down; a fresh one is created on the next onListenerConnected.
|
||||
connected = false
|
||||
pushExecutor?.let { exec ->
|
||||
pushExecutor = null
|
||||
exec.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
// Defensive: onListenerDisconnected normally clears this first, but
|
||||
// shut down here too in case onDestroy fires without a prior disconnect.
|
||||
connected = false
|
||||
pushExecutor?.shutdown()
|
||||
pushExecutor = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LedGrabNotifListener"
|
||||
private const val PY_MODULE = "ledgrab.core.processing.os_notification_listener"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.Manifest
|
||||
import android.animation.ObjectAnimator
|
||||
import android.animation.ValueAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
@@ -13,12 +16,17 @@ import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewStub
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.widget.Button
|
||||
import android.widget.CheckBox
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import android.app.Activity
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -26,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
@@ -46,25 +55,54 @@ class MainActivity : Activity() {
|
||||
private const val SERVER_PORT = 8080
|
||||
private const val REQUEST_MEDIA_PROJECTION = 1001
|
||||
private const val REQUEST_POST_NOTIFICATIONS = 1002
|
||||
private const val REQUEST_RECORD_AUDIO = 1003
|
||||
private const val REQUEST_CAMERA = 1004
|
||||
private const val REQUEST_BLUETOOTH = 1005
|
||||
private const val QR_SIZE_PX = 560
|
||||
private const val NOTIF_PREFS = "ledgrab_notif"
|
||||
private const val KEY_NOTIF_ACCESS_PROMPTED = "notif_access_prompted"
|
||||
}
|
||||
|
||||
// Stopped-state views (always inflated).
|
||||
private lateinit var stoppedPanel: View
|
||||
private lateinit var 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
|
||||
private lateinit var grantNotificationButton: Button
|
||||
private lateinit var grantUsageAccessButton: Button
|
||||
|
||||
// Running-state views (lazy-inflated via ViewStub).
|
||||
private lateinit var runningPanelStub: ViewStub
|
||||
private var runningPanel: View? = null
|
||||
private var urlText: TextView? = null
|
||||
private var qrImage: ImageView? = null
|
||||
private var stopButtonRunning: Button? = null
|
||||
private var statusDot: View? = null
|
||||
private var statusDotAnimator: ObjectAnimator? = null
|
||||
|
||||
// Cache of the most recently rendered QR (and the URL it encodes).
|
||||
// updateUI() runs on every onResume (HDMI-CEC wakes, app switches,
|
||||
// overlay dismissal, etc.). Rebuilding the 560×560 bitmap each time
|
||||
// is wasteful — usually the IP and key are unchanged. Cache and
|
||||
// short-circuit when the URL matches.
|
||||
private var cachedQrUrl: String? = null
|
||||
private var cachedQrBitmap: Bitmap? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Install the splash screen BEFORE super.onCreate so the system
|
||||
// keeps it on screen until our first frame is ready. This hides
|
||||
// the Chaquopy stdlib unpack delay on cold first launch.
|
||||
val splashScreen = installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Surface fatal Python init errors instead of crashing.
|
||||
val initError = (application as? LedGrabApp)?.initError
|
||||
if (initError != null) {
|
||||
// Tell the splash screen to dismiss immediately — we're
|
||||
// about to render an error screen, not the main UI.
|
||||
splashScreen.setKeepOnScreenCondition { false }
|
||||
showFatalErrorScreen(initError)
|
||||
return
|
||||
}
|
||||
@@ -72,39 +110,71 @@ class MainActivity : Activity() {
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
stoppedPanel = findViewById(R.id.stopped_panel)
|
||||
runningPanel = findViewById(R.id.running_panel)
|
||||
runningPanelStub = findViewById(R.id.running_panel_stub)
|
||||
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)
|
||||
grantNotificationButton = findViewById(R.id.grant_notification_button)
|
||||
grantUsageAccessButton = findViewById(R.id.grant_usage_access_button)
|
||||
|
||||
val versionName = packageManager
|
||||
.getPackageInfo(packageName, 0).versionName
|
||||
val versionName = packageManager.getPackageInfo(packageName, 0).versionName
|
||||
versionText.text = getString(R.string.version_prefix, versionName ?: "?")
|
||||
|
||||
autostartPrefs = AutostartPrefs(this)
|
||||
// Autostart only takes effect on rooted devices. Hide the
|
||||
// checkbox entirely on unrooted hardware instead of showing a
|
||||
// disabled-but-visible control, which reads as broken UI from
|
||||
// across the room.
|
||||
if (Root.looksRooted()) {
|
||||
autostartCheck.visibility = View.VISIBLE
|
||||
autostartCheck.isChecked = autostartPrefs.isEnabled
|
||||
// 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()
|
||||
}
|
||||
} else {
|
||||
autostartCheck.visibility = View.GONE
|
||||
}
|
||||
|
||||
grantNotificationButton.setOnClickListener { openNotificationListenerSettings() }
|
||||
grantUsageAccessButton.setOnClickListener { openUsageAccessSettings() }
|
||||
toggleButton.setOnClickListener { startCapture() }
|
||||
stopButtonRunning.setOnClickListener { stopCaptureService() }
|
||||
|
||||
updateStoppedPermissionButtons()
|
||||
updateUI()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopStatusDotPulse()
|
||||
uiScope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
// ObjectAnimator retains a hard reference to the dot View. On
|
||||
// backgrounded TV apps onDestroy may never fire, so cancel here
|
||||
// to avoid leaking the entire view hierarchy through an
|
||||
// INFINITE-repeat animator.
|
||||
stopStatusDotPulse()
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (!::stoppedPanel.isInitialized) return
|
||||
// Restart the pulse if we returned to the foreground while the
|
||||
// service is still running. The running panel's view may have been
|
||||
// recreated; ensureRunningPanelInflated already keys off the field
|
||||
// reference. When stopped, refresh the notification-access button —
|
||||
// the user may have just granted/revoked access in Settings.
|
||||
if (CaptureService.isRunning) {
|
||||
updateUI()
|
||||
} else {
|
||||
updateStoppedPermissionButtons()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to go through the MediaProjection consent flow or
|
||||
* jump straight into root capture. Root check is fast but may block
|
||||
@@ -112,20 +182,25 @@ class MainActivity : Activity() {
|
||||
* 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.
|
||||
// dialog; running it on the Main thread caused ANRs. Render an
|
||||
// explicit "starting" state so the button doesn't look frozen.
|
||||
val originalText = toggleButton.text
|
||||
toggleButton.isEnabled = false
|
||||
statusText.text = "Checking root access…"
|
||||
toggleButton.text = getString(R.string.btn_starting)
|
||||
statusText.text = getString(R.string.status_checking_root)
|
||||
uiScope.launch(Dispatchers.IO) {
|
||||
val rooted = Root.requestGrant()
|
||||
// runInterruptible so a config change (rotation) during the
|
||||
// up-to-10s `su` probe cancels the coroutine AND interrupts the
|
||||
// blocking probe thread — Root.requestGrant honours the interrupt,
|
||||
// destroys the su child, and rethrows, so we don't leak the
|
||||
// process + drain thread. Without this, IO-dispatcher cancellation
|
||||
// would not interrupt the blocking waitFor().
|
||||
val rooted = runInterruptible { Root.requestGrant() }
|
||||
withContext(Dispatchers.Main) {
|
||||
toggleButton.isEnabled = true
|
||||
toggleButton.text = originalText
|
||||
statusText.text = ""
|
||||
if (rooted) {
|
||||
Log.i(TAG, "Root available — skipping MediaProjection consent")
|
||||
@@ -145,6 +220,9 @@ class MainActivity : Activity() {
|
||||
|
||||
private fun startRootCaptureService() {
|
||||
ensureNotificationPermission()
|
||||
ensureNotificationListenerAccess()
|
||||
ensureCameraPermission()
|
||||
ensureBluetoothPermissions()
|
||||
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
|
||||
updateUI()
|
||||
}
|
||||
@@ -156,7 +234,7 @@ class MainActivity : Activity() {
|
||||
if (resultCode == RESULT_OK && data != null) {
|
||||
startCaptureService(resultCode, data)
|
||||
} else {
|
||||
statusText.text = "Permission denied — screen capture requires authorization"
|
||||
statusText.text = getString(R.string.status_permission_denied)
|
||||
Log.w(TAG, "MediaProjection permission denied")
|
||||
}
|
||||
}
|
||||
@@ -164,6 +242,10 @@ class MainActivity : Activity() {
|
||||
|
||||
private fun startCaptureService(resultCode: Int, resultData: Intent) {
|
||||
ensureNotificationPermission()
|
||||
ensureNotificationListenerAccess()
|
||||
ensureAudioPermission()
|
||||
ensureCameraPermission()
|
||||
ensureBluetoothPermissions()
|
||||
val intent = CaptureService.createIntent(this, resultCode, resultData)
|
||||
ContextCompat.startForegroundService(this, intent)
|
||||
updateUI()
|
||||
@@ -174,42 +256,130 @@ class MainActivity : Activity() {
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private fun updateUI() {
|
||||
if (CaptureService.isRunning) {
|
||||
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
|
||||
val url = "http://$localIp:$SERVER_PORT"
|
||||
private fun ensureRunningPanelInflated(): View {
|
||||
runningPanel?.let { return it }
|
||||
val view = runningPanelStub.inflate()
|
||||
urlText = view.findViewById(R.id.url_text)
|
||||
qrImage = view.findViewById(R.id.qr_image)
|
||||
stopButtonRunning = view.findViewById(R.id.stop_button_running)
|
||||
statusDot = view.findViewById(R.id.status_dot)
|
||||
stopButtonRunning?.setOnClickListener { stopCaptureService() }
|
||||
runningPanel = view
|
||||
return view
|
||||
}
|
||||
|
||||
urlText.text = url
|
||||
qrImage.setImageBitmap(null)
|
||||
private fun updateUI() {
|
||||
// Fatal-init-error path took over setContentView and the
|
||||
// lateinit view fields are unassigned. Guard so any future
|
||||
// caller (Resume, broadcast receiver, etc.) doesn't NPE.
|
||||
if (!::stoppedPanel.isInitialized) return
|
||||
if (CaptureService.isRunning) {
|
||||
val running = ensureRunningPanelInflated()
|
||||
val localIp = NetworkUtils.getLocalIpAddress(this)
|
||||
if (localIp == null) {
|
||||
// No network — show the no-network state inside the
|
||||
// stopped panel and keep capture stopped. The service
|
||||
// is alive (capture works on loopback) but the URL/QR
|
||||
// are useless without a routable address.
|
||||
statusText.text = getString(R.string.status_no_network)
|
||||
stoppedPanel.visibility = View.VISIBLE
|
||||
versionText.visibility = View.VISIBLE
|
||||
running.visibility = View.GONE
|
||||
toggleButton.requestFocus()
|
||||
return
|
||||
}
|
||||
|
||||
val displayUrl = "http://$localIp:$SERVER_PORT"
|
||||
val qrUrl = qrUrlFor(displayUrl)
|
||||
|
||||
urlText?.text = displayUrl
|
||||
val cachedForUrl = cachedQrBitmap?.takeIf { cachedQrUrl == qrUrl }
|
||||
if (cachedForUrl != null) {
|
||||
qrImage?.setImageBitmap(cachedForUrl)
|
||||
} else {
|
||||
qrImage?.setImageBitmap(null)
|
||||
// Build the bitmap pixels off the Main thread — encode + 313k
|
||||
// setPixel calls were noticeably janky on slow TV boxes.
|
||||
uiScope.launch(Dispatchers.Default) {
|
||||
val bitmap = generateQrCode(url)
|
||||
val bitmap = generateQrCode(qrUrl)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (CaptureService.isRunning && urlText.text == url) {
|
||||
qrImage.setImageBitmap(bitmap)
|
||||
if (CaptureService.isRunning && urlText?.text == displayUrl) {
|
||||
cachedQrUrl = qrUrl
|
||||
cachedQrBitmap = bitmap
|
||||
qrImage?.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stoppedPanel.visibility = View.GONE
|
||||
versionText.visibility = View.GONE
|
||||
runningPanel.visibility = View.VISIBLE
|
||||
stopButtonRunning.requestFocus()
|
||||
running.visibility = View.VISIBLE
|
||||
stopButtonRunning?.requestFocus()
|
||||
startStatusDotPulse()
|
||||
} else {
|
||||
urlText.text = ""
|
||||
qrImage.setImageBitmap(null)
|
||||
stopStatusDotPulse()
|
||||
urlText?.text = ""
|
||||
qrImage?.setImageBitmap(null)
|
||||
// Drop the cached bitmap so a Start → IP change → Start
|
||||
// sequence rebuilds the QR for the new address.
|
||||
cachedQrUrl = null
|
||||
cachedQrBitmap = null
|
||||
|
||||
runningPanel.visibility = View.GONE
|
||||
runningPanel?.visibility = View.GONE
|
||||
stoppedPanel.visibility = View.VISIBLE
|
||||
versionText.visibility = View.VISIBLE
|
||||
toggleButton.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the URL we encode into the QR. Embeds the API key as a
|
||||
* URL fragment (``#k=<token>``) so:
|
||||
* - The token never appears in HTTP requests (fragments aren't
|
||||
* sent over the wire) — no access-log leak.
|
||||
* - The frontend can read [location.hash] on first visit and
|
||||
* persist the key to localStorage (see static/js/app.ts).
|
||||
* - The visible URL chip stays short and human-readable.
|
||||
*
|
||||
* The chip text in [updateUI] intentionally uses the *base* URL
|
||||
* (without the fragment) so a human reading the URL out loud
|
||||
* doesn't have to dictate 64 hex chars; only the QR carries the
|
||||
* key. Do not collapse these into a single string — that would
|
||||
* leak the key onto the screen.
|
||||
*/
|
||||
private fun qrUrlFor(base: String): String {
|
||||
val key = (application as? LedGrabApp)?.apiKeyManager?.apiKey
|
||||
return if (key.isNullOrBlank()) base else "$base/#k=$key"
|
||||
}
|
||||
|
||||
private fun startStatusDotPulse() {
|
||||
val dot = statusDot ?: return
|
||||
if (statusDotAnimator?.isStarted == true) return
|
||||
val animator = ObjectAnimator.ofFloat(dot, "alpha", 1f, 0.35f).apply {
|
||||
duration = 900
|
||||
repeatCount = ValueAnimator.INFINITE
|
||||
repeatMode = ValueAnimator.REVERSE
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
}
|
||||
animator.start()
|
||||
statusDotAnimator = animator
|
||||
}
|
||||
|
||||
private fun stopStatusDotPulse() {
|
||||
statusDotAnimator?.cancel()
|
||||
statusDotAnimator = null
|
||||
statusDot?.alpha = 1f
|
||||
}
|
||||
|
||||
private fun generateQrCode(text: String): Bitmap {
|
||||
val size = 560
|
||||
val size = QR_SIZE_PX
|
||||
val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size)
|
||||
// ALPHA_8 = 1 byte/px instead of 2 (RGB_565) or 4 (ARGB_8888).
|
||||
// The ImageView gets tinted white via the matrix — for a pure
|
||||
// black-and-white QR that's all we need and it halves heap usage
|
||||
// compared to the previous RGB_565 path.
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val pixels = IntArray(size * size)
|
||||
for (y in 0 until size) {
|
||||
val rowOffset = y * size
|
||||
@@ -218,34 +388,54 @@ class MainActivity : Activity() {
|
||||
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).
|
||||
* Stack trace is hidden behind a "Show details" toggle so we don't
|
||||
* print user-path data on shared TV screens by default.
|
||||
*/
|
||||
private fun showFatalErrorScreen(error: Throwable) {
|
||||
Log.e(TAG, "Fatal init error — showing error screen", error)
|
||||
val stackText = android.util.Log.getStackTraceString(error)
|
||||
val container = android.widget.LinearLayout(this).apply {
|
||||
orientation = android.widget.LinearLayout.VERTICAL
|
||||
val stackText = Log.getStackTraceString(error)
|
||||
val container = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(48, 48, 48, 48)
|
||||
}
|
||||
val title = TextView(this).apply {
|
||||
text = "LedGrab failed to start"
|
||||
text = getString(R.string.fatal_title)
|
||||
textSize = 22f
|
||||
}
|
||||
val description = TextView(this).apply {
|
||||
text = getString(R.string.fatal_body_prefix)
|
||||
textSize = 14f
|
||||
setPadding(0, 24, 0, 12)
|
||||
}
|
||||
val body = TextView(this).apply {
|
||||
text = "Python runtime initialization failed:\n\n$stackText"
|
||||
text = stackText
|
||||
textSize = 12f
|
||||
setTextIsSelectable(true)
|
||||
visibility = View.GONE
|
||||
}
|
||||
val scroll = ScrollView(this).apply {
|
||||
addView(body)
|
||||
visibility = View.GONE
|
||||
}
|
||||
val toggleBtn = Button(this).apply {
|
||||
text = getString(R.string.fatal_show_details)
|
||||
setOnClickListener {
|
||||
val showing = scroll.visibility == View.VISIBLE
|
||||
scroll.visibility = if (showing) View.GONE else View.VISIBLE
|
||||
body.visibility = scroll.visibility
|
||||
text = getString(
|
||||
if (showing) R.string.fatal_show_details else R.string.fatal_hide_details,
|
||||
)
|
||||
}
|
||||
}
|
||||
val copyBtn = Button(this).apply {
|
||||
text = "Copy log"
|
||||
text = getString(R.string.fatal_copy_log)
|
||||
setOnClickListener {
|
||||
val cm = getSystemService(CLIPBOARD_SERVICE)
|
||||
as android.content.ClipboardManager
|
||||
@@ -254,19 +444,20 @@ class MainActivity : Activity() {
|
||||
)
|
||||
}
|
||||
}
|
||||
val scroll = android.widget.ScrollView(this).apply { addView(body) }
|
||||
container.addView(title)
|
||||
container.addView(description)
|
||||
container.addView(toggleBtn)
|
||||
container.addView(copyBtn)
|
||||
container.addView(scroll)
|
||||
setContentView(container)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user to exempt LedGrab from battery optimization. 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.
|
||||
* Prompt the user to exempt LedGrab from battery optimization.
|
||||
* Strictly a phone-side concern (Doze/App Standby kill the FG
|
||||
* service after hours of sleep); essentially a no-op on TV boxes.
|
||||
* Only asked when autostart is turned on, which is itself only
|
||||
* available on rooted devices.
|
||||
*
|
||||
* Play Store flags REQUEST_IGNORE_BATTERY_OPTIMIZATIONS by default
|
||||
* — LedGrab's ambient-capture use case falls under the documented
|
||||
@@ -311,4 +502,152 @@ class MainActivity : Activity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request RECORD_AUDIO (API 29+) so the capture service can capture
|
||||
* system playback audio for audio-reactive lighting. Fire-and-forget,
|
||||
* like [ensureNotificationPermission]: capture still works without it
|
||||
* (just no audio), so we don't block on the result. If first granted
|
||||
* here, audio becomes available on the next Start.
|
||||
*/
|
||||
private fun ensureAudioPermission() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return
|
||||
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.RECORD_AUDIO),
|
||||
REQUEST_RECORD_AUDIO,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request CAMERA so the capture service can open the device camera for
|
||||
* on-device webcam capture. Fire-and-forget, like [ensureAudioPermission]:
|
||||
* capture still works without it (just no camera engine), so we don't block
|
||||
* on the result. Gated on actual camera hardware via FEATURE_CAMERA_ANY so
|
||||
* camera-less TV boxes (the common case) never see the prompt. The camera
|
||||
* is opened on demand only while a camera source is active — granting this
|
||||
* does not keep the camera on. If first granted here, the camera engine
|
||||
* becomes available on the next Start.
|
||||
*/
|
||||
private fun ensureCameraPermission() {
|
||||
if (!packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) return
|
||||
if (checkSelfPermission(Manifest.permission.CAMERA)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.CAMERA),
|
||||
REQUEST_CAMERA,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request BLUETOOTH_SCAN + BLUETOOTH_CONNECT (API 31+) so the embedded
|
||||
* server can discover and drive BLE LED controllers (SP110E / Triones /
|
||||
* Zengge). On API < 31 these are install-time legacy permissions
|
||||
* (BLUETOOTH / BLUETOOTH_ADMIN / ACCESS_FINE_LOCATION, maxSdk=30) and
|
||||
* need no runtime grant — so this is a no-op there. Fire-and-forget,
|
||||
* like [ensureAudioPermission]: screen capture works without BLE, and
|
||||
* BleBridge degrades gracefully (empty scan / failed connect) when the
|
||||
* grant is denied, so we don't block on the result. If first granted
|
||||
* here, BLE devices become reachable on the next scan/connect.
|
||||
*/
|
||||
private fun ensureBluetoothPermissions() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||
val needed = listOf(
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
).filter {
|
||||
checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (needed.isEmpty()) return
|
||||
@Suppress("DEPRECATION")
|
||||
requestPermissions(needed.toTypedArray(), REQUEST_BLUETOOTH)
|
||||
}
|
||||
|
||||
/** Whether the user has granted notification-listener access to this app. */
|
||||
private fun isNotificationAccessGranted(): Boolean =
|
||||
NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)
|
||||
|
||||
/** Open the system Notification-access screen (manual affordance / re-grant). */
|
||||
private fun openNotificationListenerSettings() {
|
||||
runCatching {
|
||||
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
|
||||
}.onFailure { Log.w(TAG, "Notification-access settings unavailable: ${it.message}") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Usage Access (PACKAGE_USAGE_STATS) is granted — needed by the
|
||||
* foreground-app automation rule. Delegates to the bridge's AppOps check.
|
||||
*/
|
||||
private fun isUsageAccessGranted(): Boolean = ForegroundAppBridge.hasUsageAccess()
|
||||
|
||||
/**
|
||||
* Open the system Usage-Access screen so the user can grant LedGrab access
|
||||
* for the foreground-app automation rule. Falls back to the generic Settings
|
||||
* screen on TV-box OEM builds that strip the dedicated intent.
|
||||
*/
|
||||
private fun openUsageAccessSettings() {
|
||||
runCatching {
|
||||
startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS))
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Usage-access settings unavailable: ${it.message}")
|
||||
runCatching { startActivity(Intent(Settings.ACTION_SETTINGS)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt-once-then-remember: the first time capture starts without
|
||||
* notification-listener access, open the settings screen so the user can
|
||||
* grant it — then never nag again (the manual "Grant notification access"
|
||||
* button stays available). Fire-and-forget like [ensureNotificationPermission].
|
||||
*/
|
||||
private fun ensureNotificationListenerAccess() {
|
||||
if (isNotificationAccessGranted()) return
|
||||
val prefs = getSharedPreferences(NOTIF_PREFS, MODE_PRIVATE)
|
||||
if (prefs.getBoolean(KEY_NOTIF_ACCESS_PROMPTED, false)) return
|
||||
prefs.edit().putBoolean(KEY_NOTIF_ACCESS_PROMPTED, true).apply()
|
||||
openNotificationListenerSettings()
|
||||
}
|
||||
|
||||
/**
|
||||
* Show each "Grant <permission> access" button only while that access is
|
||||
* missing, then re-wire the D-pad focus chain. Called on create and on resume
|
||||
* (access can change in Settings while we're backgrounded). The usage-access
|
||||
* button is a passive affordance (no auto-prompt at capture start) — the
|
||||
* primary guidance is the web-UI banner when an Android app rule needs it.
|
||||
*/
|
||||
private fun updateStoppedPermissionButtons() {
|
||||
if (!::grantNotificationButton.isInitialized) return
|
||||
grantNotificationButton.visibility =
|
||||
if (isNotificationAccessGranted()) View.GONE else View.VISIBLE
|
||||
grantUsageAccessButton.visibility =
|
||||
if (isUsageAccessGranted()) View.GONE else View.VISIBLE
|
||||
wireStoppedFocusChain()
|
||||
}
|
||||
|
||||
/**
|
||||
* Link the visible stopped-panel controls into a single up/down D-pad chain.
|
||||
* The optional controls (the grant-access buttons and the root-only autostart
|
||||
* checkbox) may be GONE, so the chain is computed from whatever is visible —
|
||||
* a static nextFocus pointing at a GONE view would strand the focus on a TV
|
||||
* remote.
|
||||
*/
|
||||
private fun wireStoppedFocusChain() {
|
||||
val chain = listOfNotNull(
|
||||
toggleButton,
|
||||
grantNotificationButton.takeIf { it.visibility == View.VISIBLE },
|
||||
grantUsageAccessButton.takeIf { it.visibility == View.VISIBLE },
|
||||
autostartCheck.takeIf { it.visibility == View.VISIBLE },
|
||||
)
|
||||
chain.forEachIndexed { i, view ->
|
||||
view.nextFocusUpId = (chain.getOrNull(i - 1) ?: view).id
|
||||
view.nextFocusDownId = (chain.getOrNull(i + 1) ?: view).id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.ledgrab.android
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkProperties
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import java.net.Inet4Address
|
||||
|
||||
/**
|
||||
@@ -11,18 +13,58 @@ import java.net.Inet4Address
|
||||
object NetworkUtils {
|
||||
|
||||
/**
|
||||
* Return the device's local IPv4 address on the active network,
|
||||
* or `null` if unavailable.
|
||||
* Return the device's local IPv4 address, preferring (in order):
|
||||
* - Ethernet (wired TV-box link)
|
||||
* - Wi-Fi
|
||||
* - any other transport
|
||||
* - whatever the active network reports
|
||||
*
|
||||
* Returns ``null`` only when no IPv4 link addresses exist at all.
|
||||
*
|
||||
* Why not just ``activeNetwork``: on TV boxes with both Ethernet
|
||||
* AND Wi-Fi connected, Android's active-network heuristic can
|
||||
* pick Wi-Fi while the user's phone is on the Ethernet subnet —
|
||||
* leading to a URL/QR that the phone can't reach.
|
||||
*/
|
||||
fun getLocalIpAddress(context: Context): String? {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = cm.activeNetwork ?: return null
|
||||
val props: LinkProperties = cm.getLinkProperties(network) ?: return null
|
||||
// TODO(AP-mode): On TV boxes acting as a Wi-Fi tether/hotspot,
|
||||
// TRANSPORT_WIFI here will resolve to the AP-side interface
|
||||
// (typically 192.168.43.x) which clients on the user's actual
|
||||
// home LAN can't reach. Detecting AP mode requires the @SystemApi
|
||||
// WifiManager.getWifiApState reflection trick — defer until a
|
||||
// user reports needing it.
|
||||
val networks = cm.allNetworks
|
||||
if (networks.isEmpty()) return ipv4Of(cm, cm.activeNetwork ?: return null)
|
||||
|
||||
val ranked = networks
|
||||
.mapNotNull { n ->
|
||||
val caps = cm.getNetworkCapabilities(n) ?: return@mapNotNull null
|
||||
val rank = when {
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 0
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 1
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> 3
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 4
|
||||
else -> 2
|
||||
}
|
||||
Triple(rank, n, caps)
|
||||
}
|
||||
.sortedBy { it.first }
|
||||
|
||||
for ((_, network, _) in ranked) {
|
||||
val ip = ipv4Of(cm, network)
|
||||
if (ip != null) return ip
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun ipv4Of(cm: ConnectivityManager, network: Network): String? {
|
||||
val props: LinkProperties = cm.getLinkProperties(network) ?: return null
|
||||
return props.linkAddresses
|
||||
.asSequence()
|
||||
.map { it.address }
|
||||
.filterIsInstance<Inet4Address>()
|
||||
.firstOrNull { !it.isLoopbackAddress }
|
||||
.firstOrNull { !it.isLoopbackAddress && !it.isLinkLocalAddress }
|
||||
?.hostAddress
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ 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`.
|
||||
* Frame data crosses the JNI boundary as a `ByteArray` (reused across
|
||||
* frames — see ScreenCapture / RootScreenrecord for buffer pools).
|
||||
*/
|
||||
class PythonBridge(private val context: Context) {
|
||||
|
||||
@@ -27,6 +28,7 @@ class PythonBridge(private val context: Context) {
|
||||
// single-writer/single-reader pattern we have here.
|
||||
@Volatile private var mediaProjectionEngine: PyObject? = null
|
||||
@Volatile private var rootEngine: PyObject? = null
|
||||
@Volatile private var androidAudioEngine: PyObject? = null
|
||||
|
||||
/**
|
||||
* Configure the MediaProjection engine with screen dimensions.
|
||||
@@ -52,12 +54,60 @@ class PythonBridge(private val context: Context) {
|
||||
Log.i(TAG, "Root screenrecord engine configured: ${width}x${height}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the Android playback-capture audio engine with the format
|
||||
* actually negotiated by [AudioCapture]'s `AudioRecord`. Must be called
|
||||
* before [pushAudio]. Caches the module handle for the per-block fast
|
||||
* path (same pattern as [configureCapture]).
|
||||
*/
|
||||
fun configureAudio(sampleRate: Int, channels: Int, chunkFrames: Int) {
|
||||
val py = Python.getInstance()
|
||||
val engine = py.getModule("ledgrab.core.audio.android_audio_engine")
|
||||
engine.callAttr("configure", sampleRate, channels, chunkFrames)
|
||||
androidAudioEngine = engine
|
||||
Log.i(TAG, "Android audio engine configured: sr=$sampleRate ch=$channels chunk=$chunkFrames")
|
||||
}
|
||||
|
||||
/**
|
||||
* Push one interleaved little-endian float32 PCM block to the Python
|
||||
* audio engine. Called from [AudioCapture]'s capture thread. The byte
|
||||
* array crosses the JNI boundary; Python copies it on receipt, so the
|
||||
* caller may reuse the same buffer for the next block.
|
||||
*/
|
||||
fun pushAudio(pcmFloat32: ByteArray) {
|
||||
if (!running) return
|
||||
val engine = androidAudioEngine ?: return
|
||||
try {
|
||||
engine.callAttr("push_samples", pcmFloat32)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to push audio: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate the Python audio engine. Called from [AudioCapture.stop].
|
||||
*/
|
||||
fun shutdownAudio() {
|
||||
val engine = androidAudioEngine ?: return
|
||||
try {
|
||||
engine.callAttr("shutdown")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to shut down audio engine: ${e.message}")
|
||||
}
|
||||
androidAudioEngine = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the LedGrab FastAPI server on a background thread.
|
||||
*
|
||||
* This blocks until [stopServer] is called, so it runs in its own thread.
|
||||
* Passes [apiKey] through so the Python server's auth gate accepts
|
||||
* Bearer-authenticated LAN requests; null disables auth (loopback
|
||||
* only — see [ApiKeyManager]).
|
||||
*
|
||||
* This blocks until [stopServer] is called, so it runs in its own
|
||||
* thread.
|
||||
*/
|
||||
fun startServer(port: Int = 8080) {
|
||||
fun startServer(port: Int = 8080, apiKey: String? = null) {
|
||||
if (running) {
|
||||
Log.w(TAG, "Server already running")
|
||||
return
|
||||
@@ -71,7 +121,11 @@ class PythonBridge(private val context: Context) {
|
||||
Log.i(TAG, "Starting Python server (dataDir=$dataDir, port=$port)")
|
||||
val py = Python.getInstance()
|
||||
val entry = py.getModule("ledgrab.android_entry")
|
||||
if (apiKey != null) {
|
||||
entry.callAttr("start_server", dataDir, port, apiKey)
|
||||
} else {
|
||||
entry.callAttr("start_server", dataDir, port)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Python server error", e)
|
||||
} finally {
|
||||
@@ -106,7 +160,8 @@ class PythonBridge(private val context: Context) {
|
||||
*
|
||||
* Called from [ScreenCapture] on the capture thread. The byte array
|
||||
* crosses the JNI boundary — keep frames small (downscale to 480p
|
||||
* before calling).
|
||||
* before calling) and pass reusable buffers (see ScreenCapture's
|
||||
* buffer pool).
|
||||
*/
|
||||
fun pushFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
|
||||
if (!running) return
|
||||
|
||||
@@ -20,6 +20,11 @@ import java.util.concurrent.TimeUnit
|
||||
object Root {
|
||||
private const val TAG = "Root"
|
||||
|
||||
// Slice length for the cancellation-aware su probe wait loop. Short
|
||||
// enough that coroutine cancellation is honoured promptly, long enough
|
||||
// to avoid busy-spinning while Magisk's grant dialog is up.
|
||||
private const val POLL_SLICE_MS = 100L
|
||||
|
||||
private val SU_PATHS = listOf(
|
||||
"/system/bin/su",
|
||||
"/system/xbin/su",
|
||||
@@ -49,17 +54,19 @@ object Root {
|
||||
return false
|
||||
}
|
||||
|
||||
var process: Process? = null
|
||||
val granted = try {
|
||||
// redirectErrorStream merges stderr into stdout so a single
|
||||
// drain thread is enough — avoids the classic pipe-buffer
|
||||
// deadlock where waitFor() blocks because stderr filled up.
|
||||
val process = ProcessBuilder("su", "-c", "id")
|
||||
val proc = ProcessBuilder("su", "-c", "id")
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
process = proc
|
||||
val outputBuilder = StringBuilder()
|
||||
val drain = Thread({
|
||||
try {
|
||||
BufferedReader(InputStreamReader(process.inputStream)).use { r ->
|
||||
BufferedReader(InputStreamReader(proc.inputStream)).use { r ->
|
||||
val buf = CharArray(512)
|
||||
while (true) {
|
||||
val n = r.read(buf)
|
||||
@@ -72,17 +79,35 @@ object Root {
|
||||
}
|
||||
}, "Root-su-drain").apply { isDaemon = true; start() }
|
||||
|
||||
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
||||
// Cancellation-aware wait: callers run this on a coroutine
|
||||
// (MainActivity wraps it in runInterruptible), so a config change
|
||||
// mid-probe cancels the coroutine and interrupts this thread.
|
||||
// Poll waitFor() in short slices and honour interruption so we
|
||||
// don't leak the `su` child + its drain thread for up to 10s.
|
||||
// The catch(InterruptedException) below destroys the process; we
|
||||
// re-arm the interrupt and rethrow so coroutine cancellation
|
||||
// propagates cleanly.
|
||||
val deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds)
|
||||
var finished = false
|
||||
while (System.nanoTime() < deadlineNanos) {
|
||||
if (proc.waitFor(POLL_SLICE_MS, TimeUnit.MILLISECONDS)) {
|
||||
finished = true
|
||||
break
|
||||
}
|
||||
// Throws InterruptedException if the thread was interrupted
|
||||
// by coroutine cancellation — handled below to tear down.
|
||||
if (Thread.interrupted()) throw InterruptedException("su probe cancelled")
|
||||
}
|
||||
if (!finished) {
|
||||
process.destroyForcibly()
|
||||
proc.destroyForcibly()
|
||||
drain.join(500)
|
||||
Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s")
|
||||
false
|
||||
} else {
|
||||
drain.join(500)
|
||||
val output = synchronized(outputBuilder) { outputBuilder.toString() }
|
||||
if (process.exitValue() != 0) {
|
||||
Log.w(TAG, "su -c id exited with ${process.exitValue()} output='${output.trim()}'")
|
||||
if (proc.exitValue() != 0) {
|
||||
Log.w(TAG, "su -c id exited with ${proc.exitValue()} output='${output.trim()}'")
|
||||
false
|
||||
} else {
|
||||
val rooted = output.contains("uid=0")
|
||||
@@ -90,8 +115,17 @@ object Root {
|
||||
rooted
|
||||
}
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
// Coroutine cancelled mid-probe (e.g. config change). Kill the
|
||||
// su child so it doesn't outlive the cancelled work, re-arm the
|
||||
// interrupt flag, and rethrow so the coroutine cancels cleanly.
|
||||
// Do NOT cache a result — the probe never completed.
|
||||
runCatching { process?.destroyForcibly() }
|
||||
Thread.currentThread().interrupt()
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "su invocation failed: ${e.message}")
|
||||
runCatching { process?.destroyForcibly() }
|
||||
false
|
||||
}
|
||||
|
||||
@@ -100,14 +134,41 @@ object Root {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Run a command as root.
|
||||
*
|
||||
* The [argv] array is passed to `su -c` as **a single string** built by
|
||||
* shell-quoting each element. This prevents the shell-injection class
|
||||
* of bug where a caller passes user-influenced data containing
|
||||
* spaces, semicolons, or backticks: each element is treated as a
|
||||
* single shell token regardless of contents.
|
||||
*
|
||||
* Returns true on exit-zero. Failure invalidates the cached grant so
|
||||
* the next [requestGrant] re-checks (covers cases like Magisk grant
|
||||
* being revoked mid-session).
|
||||
*/
|
||||
@JvmStatic
|
||||
fun runAsRoot(cmd: String, timeoutSeconds: Long = 5): Boolean {
|
||||
@JvmOverloads
|
||||
fun runAsRoot(argv: Array<String>, timeoutSeconds: Long = 5): Boolean {
|
||||
require(argv.isNotEmpty()) { "runAsRoot called with empty argv" }
|
||||
val quoted = argv.joinToString(" ") { shellQuote(it) }
|
||||
return execSu(quoted, timeoutSeconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience for fully-trusted constant commands (e.g.
|
||||
* ``runAsRoot("pkill -TERM screenrecord")``). DO NOT pass anything
|
||||
* derived from user input through this overload — use [runAsRoot]
|
||||
* with an argv array instead so each token is quoted individually.
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun runAsRoot(command: String, timeoutSeconds: Long = 5): Boolean {
|
||||
return execSu(command, timeoutSeconds)
|
||||
}
|
||||
|
||||
private fun execSu(shellLine: String, timeoutSeconds: Long): Boolean {
|
||||
return try {
|
||||
val process = ProcessBuilder("su", "-c", cmd)
|
||||
val process = ProcessBuilder("su", "-c", shellLine)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
||||
@@ -122,12 +183,34 @@ object Root {
|
||||
true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "runAsRoot('$cmd') failed: ${e.message}")
|
||||
Log.w(TAG, "runAsRoot('$shellLine') failed: ${e.message}")
|
||||
cachedGranted = null
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POSIX-shell-style single-quote escape. Wraps in single quotes and
|
||||
* escapes embedded single quotes as ``'\''`` so shell metacharacters
|
||||
* inside [s] are inert.
|
||||
*/
|
||||
private fun shellQuote(s: String): String {
|
||||
if (s.isEmpty()) return "''"
|
||||
// Optimisation: if the string contains only safe characters,
|
||||
// skip the quoting overhead. The set is intentionally narrow —
|
||||
// notably `=` is excluded because an unquoted "FOO=bar" at the
|
||||
// start of a command would be parsed as a shell variable
|
||||
// assignment, not a literal arg. Quoting it forces literal use.
|
||||
if (s.all { it.isLetterOrDigit() || it in "_-./" }) return s
|
||||
val sb = StringBuilder(s.length + 2)
|
||||
sb.append('\'')
|
||||
for (ch in s) {
|
||||
if (ch == '\'') sb.append("'\\''") else sb.append(ch)
|
||||
}
|
||||
sb.append('\'')
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** Forget the cached grant result — useful if Magisk permission was revoked. */
|
||||
@JvmStatic
|
||||
fun invalidateCache() {
|
||||
|
||||
@@ -38,8 +38,15 @@ class RootScreenrecord(
|
||||
private const val TAG = "RootScreenrecord"
|
||||
private const val MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC
|
||||
private const val INPUT_CHUNK = 64 * 1024
|
||||
// How long to back off when MediaCodec has no input buffer free.
|
||||
// 50 ms keeps the input pump from busy-spinning if the decoder
|
||||
// is stalled (codec init, severe stall, etc.).
|
||||
private const val NO_BUFFER_BACKOFF_MS = 5L
|
||||
}
|
||||
|
||||
// Instance is single-use: stop() permanently disposes it. Callers
|
||||
// wanting to restart the pipeline must construct a new instance —
|
||||
// see CaptureService.restartRootPipeline().
|
||||
@Volatile private var process: Process? = null
|
||||
private var decoder: MediaCodec? = null
|
||||
private var imageReader: ImageReader? = null
|
||||
@@ -48,7 +55,22 @@ class RootScreenrecord(
|
||||
private var outputThread: Thread? = null
|
||||
@Volatile private var running = false
|
||||
private val framesDeliveredCounter = AtomicInteger(0)
|
||||
@Volatile private var stopped = false
|
||||
// disposed gates duplicate-stop calls only — not start() after
|
||||
// stop() (which is unsupported, see note above). Set at the START
|
||||
// of cleanup so a second concurrent stop() (rare under @Synchronized
|
||||
// but possible if a future caller drops it) doesn't re-run runCatching
|
||||
// blocks against already-released resources.
|
||||
@Volatile private var disposed = false
|
||||
// Guards process respawn vs. concurrent disposal. The input pump
|
||||
// can spawn a fresh `su -c screenrecord` after EOF; without this
|
||||
// lock, stop() could destroy the OLD process between spawn and
|
||||
// assignment, leaving the new one orphaned (GPU encoder leak).
|
||||
private val processLock = Any()
|
||||
|
||||
// Reusable RGBA buffer for ImageReader callbacks (single-threaded
|
||||
// reader callback). See ScreenCapture for the rationale: avoids
|
||||
// ~15 MB/s of per-frame garbage at 30 fps × 480×270×4 B.
|
||||
private val frameBuffer: ByteArray = ByteArray(width * height * 4)
|
||||
|
||||
/** Monotonic count of frames pushed to the Python bridge. */
|
||||
val framesDelivered: Int get() = framesDeliveredCounter.get()
|
||||
@@ -67,14 +89,15 @@ class RootScreenrecord(
|
||||
running = true
|
||||
|
||||
try {
|
||||
imageReader = buildImageReader()
|
||||
decoder = buildDecoder(imageReader!!)
|
||||
process = spawnScreenrecord() ?: run {
|
||||
val reader = buildImageReader().also { imageReader = it }
|
||||
val codec = buildDecoder(reader).also { decoder = it }
|
||||
val proc = spawnScreenrecord() ?: run {
|
||||
stop()
|
||||
return false
|
||||
}
|
||||
startInputPump(process!!.inputStream, decoder!!)
|
||||
startOutputDrain(decoder!!)
|
||||
process = proc
|
||||
startInputPump(proc.inputStream, codec)
|
||||
startOutputDrain(codec)
|
||||
Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)")
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
@@ -84,11 +107,11 @@ class RootScreenrecord(
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop everything and release resources. Idempotent. */
|
||||
/** Stop everything and release resources. Idempotent. Single-use: do not call start() again. */
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
// Order matters: signal first so worker loops drop out, then
|
||||
// stop the codec on the thread that created it (this one), then
|
||||
// join workers BEFORE releasing the codec/ImageReader they may
|
||||
@@ -107,7 +130,9 @@ class RootScreenrecord(
|
||||
// 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 {
|
||||
Root.runAsRoot(arrayOf("pkill", "-TERM", "screenrecord"), timeoutSeconds = 2)
|
||||
}
|
||||
|
||||
runCatching { decoder?.release() }
|
||||
decoder = null
|
||||
@@ -120,8 +145,13 @@ class RootScreenrecord(
|
||||
runCatching { readerThread?.join(500) }
|
||||
readerThread = null
|
||||
|
||||
// Use the same lock as the respawn path so we don't destroy a
|
||||
// not-yet-published process or leak one that was spawned after
|
||||
// we already destroyed the old reference.
|
||||
synchronized(processLock) {
|
||||
runCatching { process?.destroy() }
|
||||
process = null
|
||||
}
|
||||
|
||||
Log.i(TAG, "Root capture pipeline stopped (frames delivered: ${framesDelivered})")
|
||||
}
|
||||
@@ -131,7 +161,7 @@ class RootScreenrecord(
|
||||
readerThread = thread
|
||||
val handler = Handler(thread.looper)
|
||||
|
||||
val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
|
||||
val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 3)
|
||||
reader.setOnImageAvailableListener({ r ->
|
||||
val image = r.acquireLatestImage() ?: return@setOnImageAvailableListener
|
||||
try {
|
||||
@@ -139,19 +169,25 @@ class RootScreenrecord(
|
||||
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 ->
|
||||
val expected = rowBytes * height
|
||||
if (rowStride == rowBytes && buffer.remaining() >= expected) {
|
||||
buffer.get(frameBuffer, 0, expected)
|
||||
} else {
|
||||
for (row in 0 until height) {
|
||||
buffer.position(row * rowStride)
|
||||
buffer.get(out, row * rowBytes, rowBytes)
|
||||
buffer.get(frameBuffer, row * rowBytes, rowBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
bridge.pushRootFrame(bytes, width, height)
|
||||
// CONTRACT: frameBuffer is REUSED across frames (single-threaded
|
||||
// reader callback — no copy here). Safety depends on the Python
|
||||
// receiver copying the bytes before this callback returns and
|
||||
// overwrites the buffer for the next frame. It does:
|
||||
// PythonBridge.pushRootFrame → root_screenrecord_engine.push_frame
|
||||
// (server/src/ledgrab/core/capture_engines/root_screenrecord_engine.py)
|
||||
// does `rgba[:, :, :3].copy()`, so the queued frame owns its
|
||||
// pixels independently of this buffer. Do NOT remove that copy.
|
||||
bridge.pushRootFrame(frameBuffer, width, height)
|
||||
framesDeliveredCounter.incrementAndGet()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Root frame delivery failed: ${e.message}")
|
||||
@@ -173,18 +209,26 @@ class RootScreenrecord(
|
||||
}
|
||||
|
||||
private fun spawnScreenrecord(): Process? {
|
||||
val cmd = buildString {
|
||||
append("screenrecord")
|
||||
append(" --output-format=h264")
|
||||
append(" --size=${width}x$height")
|
||||
append(" --bit-rate=$bitRate")
|
||||
// argv form — passes safely through Root.runAsRoot's shell-quote
|
||||
// logic so future changes to flag values can't introduce injection.
|
||||
val args = arrayOf(
|
||||
"screenrecord",
|
||||
"--output-format=h264",
|
||||
"--size=${width}x$height",
|
||||
"--bit-rate=$bitRate",
|
||||
// Time limit 0 isn't supported; the largest accepted is 180s.
|
||||
// We restart the process ourselves if it exits early.
|
||||
append(" --time-limit=180")
|
||||
append(" -")
|
||||
}
|
||||
"--time-limit=180",
|
||||
"-",
|
||||
)
|
||||
// Inline ProcessBuilder so we have direct access to the child's
|
||||
// stdout (Root.runAsRoot returns Boolean). We still pass args
|
||||
// unquoted because the entire array is a fixed program+flags
|
||||
// with no user-controlled content.
|
||||
return try {
|
||||
Runtime.getRuntime().exec(arrayOf("su", "-c", cmd))
|
||||
ProcessBuilder("su", "-c", args.joinToString(" "))
|
||||
.redirectErrorStream(false)
|
||||
.start()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to spawn `su -c screenrecord`: ${e.message}")
|
||||
null
|
||||
@@ -210,21 +254,56 @@ class RootScreenrecord(
|
||||
// exits cleanly we respawn so capture survives
|
||||
// long sessions instead of freezing after ~3min.
|
||||
Log.i(TAG, "screenrecord EOF — respawning")
|
||||
synchronized(processLock) {
|
||||
runCatching { process?.destroy() }
|
||||
process = null
|
||||
}
|
||||
val next = spawnScreenrecord()
|
||||
if (next == null) {
|
||||
// Avoid a tight loop if `su` is suddenly unhappy.
|
||||
try { Thread.sleep(500) } catch (_: InterruptedException) { break }
|
||||
continue@outer
|
||||
}
|
||||
// Publish the new process under the lock so a
|
||||
// concurrent stop() either (a) sees no process,
|
||||
// tears down later, and lets us assign it for
|
||||
// the destroy on the NEXT stop call — or (b) sees
|
||||
// !running and we destroy the new process ourselves.
|
||||
val accepted = synchronized(processLock) {
|
||||
if (!running) {
|
||||
false
|
||||
} else {
|
||||
process = next
|
||||
true
|
||||
}
|
||||
}
|
||||
if (!accepted) {
|
||||
// running flipped false between EOF and now —
|
||||
// someone called stop(). Drop the new process
|
||||
// on the floor; the codec and output thread
|
||||
// are stop()'s responsibility (it's the only
|
||||
// writer to `running`, so we don't need to
|
||||
// tear them down here).
|
||||
runCatching { next.destroy() }
|
||||
break@outer
|
||||
}
|
||||
stream = next.inputStream
|
||||
continue@outer
|
||||
}
|
||||
var offset = 0
|
||||
while (offset < n && running) {
|
||||
val index = codec.dequeueInputBuffer(50_000)
|
||||
if (index < 0) continue
|
||||
if (index < 0) {
|
||||
// Codec is starved — back off briefly instead
|
||||
// of spinning. Without this, a stalled codec
|
||||
// burns 100% of one core hammering dequeue.
|
||||
try {
|
||||
Thread.sleep(NO_BUFFER_BACKOFF_MS)
|
||||
} catch (_: InterruptedException) {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
val inputBuffer = codec.getInputBuffer(index) ?: continue
|
||||
inputBuffer.clear()
|
||||
val chunk = minOf(n - offset, inputBuffer.capacity())
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PixelFormat
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.hardware.display.VirtualDisplay
|
||||
@@ -8,24 +7,26 @@ import android.media.ImageReader
|
||||
import android.media.projection.MediaProjection
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.SystemClock
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
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.
|
||||
* Frames are downscaled to roughly [targetWidth] x [targetHeight] before
|
||||
* crossing the JNI boundary to minimize overhead. The actual capture
|
||||
* dimensions preserve the source screen's aspect ratio (snapped to even
|
||||
* pixels for codec friendliness) so non-16:9 displays don't get
|
||||
* squashed.
|
||||
*/
|
||||
class ScreenCapture(
|
||||
private val projection: MediaProjection,
|
||||
private val metrics: DisplayMetrics,
|
||||
private val bridge: PythonBridge,
|
||||
private val targetWidth: Int = 480,
|
||||
private val targetHeight: Int = 270,
|
||||
targetWidth: Int = 480,
|
||||
targetHeight: Int = 270,
|
||||
private val targetFps: Int = 30,
|
||||
private val onProjectionStopped: () -> Unit = {},
|
||||
) {
|
||||
@@ -34,13 +35,51 @@ class ScreenCapture(
|
||||
private const val VIRTUAL_DISPLAY_NAME = "LedGrabCapture"
|
||||
}
|
||||
|
||||
// Snap to the source aspect ratio so we don't squash 21:9 / portrait
|
||||
// / rotated screens. Width is the budget; height follows.
|
||||
private val captureWidth: Int
|
||||
private val captureHeight: Int
|
||||
|
||||
init {
|
||||
val srcW = metrics.widthPixels.coerceAtLeast(1).toFloat()
|
||||
val srcH = metrics.heightPixels.coerceAtLeast(1).toFloat()
|
||||
val budget = targetWidth.coerceAtLeast(16)
|
||||
val aspect = srcW / srcH
|
||||
val w = budget
|
||||
val h = (w / aspect).toInt().coerceAtLeast(16)
|
||||
// Bias toward even dimensions — some encoders/ImageReaders are
|
||||
// unhappy with odd sizes when row strides come into play.
|
||||
captureWidth = (w and 1.inv()).coerceAtLeast(16)
|
||||
captureHeight = (h and 1.inv()).coerceAtLeast(16)
|
||||
if (captureWidth != targetWidth || captureHeight != targetHeight) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Capture size adjusted for ${srcW.toInt()}x${srcH.toInt()} " +
|
||||
"(${"%.2f".format(aspect)}:1) → ${captureWidth}x$captureHeight",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var virtualDisplay: VirtualDisplay? = null
|
||||
private var imageReader: ImageReader? = null
|
||||
private var captureThread: HandlerThread? = null
|
||||
private var captureHandler: Handler? = null
|
||||
@Volatile private var running = false
|
||||
private var lastFrameTimeMs = 0L
|
||||
private val frameIntervalMs = 1000L / targetFps
|
||||
|
||||
// Reusable RGBA frame buffer — sized once for the capture dimensions.
|
||||
// The capture handler is single-threaded so no synchronisation is
|
||||
// required around this buffer (each callback runs to completion
|
||||
// before the next is dispatched). Eliminates ~15 MB/s of per-frame
|
||||
// garbage at 30 fps × 480×270×4 B that previously caused GC pauses
|
||||
// on low-end TV boxes.
|
||||
private val frameBuffer: ByteArray = ByteArray(captureWidth * captureHeight * 4)
|
||||
|
||||
// Monotonic frame pacing. `nextFrameNanos` is the target render
|
||||
// time of the next frame; carrying it forward as an accumulator
|
||||
// avoids the integer-division drift the wall-clock version had
|
||||
// (e.g. 30 fps → 33 ms produced ~30.3 fps).
|
||||
private val frameIntervalNanos = (1_000_000_000L / targetFps.coerceAtLeast(1))
|
||||
private var nextFrameNanos = 0L
|
||||
|
||||
/**
|
||||
* Start capturing the screen.
|
||||
@@ -48,6 +87,7 @@ class ScreenCapture(
|
||||
fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
nextFrameNanos = SystemClock.elapsedRealtimeNanos()
|
||||
|
||||
captureThread = HandlerThread("LedGrab-Capture").also { it.start() }
|
||||
captureHandler = Handler(captureThread!!.looper)
|
||||
@@ -56,28 +96,32 @@ class ScreenCapture(
|
||||
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.
|
||||
// We're on captureHandler's thread here — calling stop()
|
||||
// directly would self-join captureThread (handler.join()
|
||||
// from inside the handler thread hangs until the join
|
||||
// timeout, then closes resources while we're STILL
|
||||
// inside this callback). Just flip `running` to halt
|
||||
// frame processing and hand off to the service; its
|
||||
// onDestroy will call stop() from the main thread,
|
||||
// which is safe to join captureThread from.
|
||||
running = false
|
||||
onProjectionStopped()
|
||||
}
|
||||
}, captureHandler)
|
||||
|
||||
imageReader = ImageReader.newInstance(
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
captureWidth,
|
||||
captureHeight,
|
||||
PixelFormat.RGBA_8888,
|
||||
2, // maxImages — double buffer
|
||||
3, // maxImages — small ring buffer; 3 is more forgiving than 2 under jitter
|
||||
)
|
||||
|
||||
imageReader?.setOnImageAvailableListener({ reader ->
|
||||
if (!running) return@setOnImageAvailableListener
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastFrameTimeMs < frameIntervalMs) {
|
||||
// Skip frame to maintain target FPS
|
||||
val now = SystemClock.elapsedRealtimeNanos()
|
||||
if (now < nextFrameNanos) {
|
||||
// Too early — drop this image to stay on cadence.
|
||||
reader.acquireLatestImage()?.close()
|
||||
return@setOnImageAvailableListener
|
||||
}
|
||||
@@ -88,26 +132,38 @@ class ScreenCapture(
|
||||
val buffer = plane.buffer
|
||||
val rowStride = plane.rowStride
|
||||
val pixelStride = plane.pixelStride
|
||||
val rowBytes = captureWidth * pixelStride
|
||||
val expected = rowBytes * captureHeight
|
||||
|
||||
// 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
|
||||
// Fill the reusable buffer. Two paths:
|
||||
// - rowStride == rowBytes: bulk get into the buffer
|
||||
// - rowStride > rowBytes: row-by-row copy stripping padding
|
||||
if (rowStride == rowBytes && buffer.remaining() >= expected) {
|
||||
buffer.get(frameBuffer, 0, expected)
|
||||
} else {
|
||||
// Strip row padding
|
||||
val rowBytes = targetWidth * pixelStride
|
||||
val bytes = ByteArray(targetWidth * targetHeight * 4)
|
||||
for (row in 0 until targetHeight) {
|
||||
for (row in 0 until captureHeight) {
|
||||
buffer.position(row * rowStride)
|
||||
buffer.get(bytes, row * rowBytes, rowBytes)
|
||||
buffer.get(frameBuffer, row * rowBytes, rowBytes)
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
bridge.pushFrame(rgbaBytes, targetWidth, targetHeight)
|
||||
lastFrameTimeMs = now
|
||||
// CONTRACT: frameBuffer is REUSED across frames (single-threaded
|
||||
// capture handler — no copy here). Safety depends on the Python
|
||||
// receiver copying the bytes before this callback returns and
|
||||
// overwrites the buffer for the next frame. It does:
|
||||
// PythonBridge.pushFrame → mediaprojection_engine.push_frame
|
||||
// (server/src/ledgrab/core/capture_engines/mediaprojection_engine.py)
|
||||
// does `rgba[:, :, :3].copy()`, so the queued frame owns its
|
||||
// pixels independently of this buffer. Do NOT remove that copy.
|
||||
bridge.pushFrame(frameBuffer, captureWidth, captureHeight)
|
||||
|
||||
// Advance the pacing accumulator. If we fell badly behind
|
||||
// (long GC, JNI stall), snap forward to "now" instead of
|
||||
// accumulating a burst of catch-up frames.
|
||||
nextFrameNanos += frameIntervalNanos
|
||||
if (now - nextFrameNanos > frameIntervalNanos * 4) {
|
||||
nextFrameNanos = now + frameIntervalNanos
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Frame processing error: ${e.message}")
|
||||
} finally {
|
||||
@@ -117,8 +173,8 @@ class ScreenCapture(
|
||||
|
||||
virtualDisplay = projection.createVirtualDisplay(
|
||||
VIRTUAL_DISPLAY_NAME,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
captureWidth,
|
||||
captureHeight,
|
||||
metrics.densityDpi,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
imageReader?.surface,
|
||||
@@ -126,7 +182,7 @@ class ScreenCapture(
|
||||
captureHandler,
|
||||
)
|
||||
|
||||
Log.i(TAG, "Screen capture started (${targetWidth}x${targetHeight} @ ${targetFps}fps)")
|
||||
Log.i(TAG, "Screen capture started (${captureWidth}x${captureHeight} @ ${targetFps}fps)")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
||||
import com.hoho.android.usbserial.driver.UsbSerialPort
|
||||
import com.hoho.android.usbserial.driver.UsbSerialProber
|
||||
@@ -54,8 +55,23 @@ object UsbSerialBridge {
|
||||
if (!initialized.compareAndSet(false, true)) return
|
||||
|
||||
val filter = IntentFilter(ACTION_USB_PERMISSION)
|
||||
val ourPackage = app.packageName
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
// Defence-in-depth: the receiver is registered as
|
||||
// RECEIVER_NOT_EXPORTED, but on pre-API-33 platforms
|
||||
// older Android versions historically defaulted to
|
||||
// exported. Also enforce the package check here so an
|
||||
// explicit-intent attack from another app on the device
|
||||
// is rejected even if the OS treats us as exported.
|
||||
if (intent.`package` != null && intent.`package` != ourPackage) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Ignoring USB permission broadcast from " +
|
||||
"package='${intent.`package`}' (not us)",
|
||||
)
|
||||
return
|
||||
}
|
||||
val granted = intent.getBooleanExtra(
|
||||
UsbManager.EXTRA_PERMISSION_GRANTED,
|
||||
false,
|
||||
@@ -69,13 +85,16 @@ object UsbSerialBridge {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
// ContextCompat handles the RECEIVER_NOT_EXPORTED flag correctly
|
||||
// across all supported API levels (it's a no-op on platforms
|
||||
// where the flag doesn't exist, and explicit on API ≥33 where
|
||||
// Android enforces it).
|
||||
ContextCompat.registerReceiver(
|
||||
app,
|
||||
receiver,
|
||||
filter,
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ctx(): Context =
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Android TV launcher banner: 320x180 landscape.
|
||||
Shown on the leanback home row. The previous build reused the square
|
||||
launcher icon, which letterboxed badly. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="320dp"
|
||||
android:height="180dp"
|
||||
android:viewportWidth="320"
|
||||
android:viewportHeight="180">
|
||||
<!-- Background -->
|
||||
<path
|
||||
android:fillColor="#0d1117"
|
||||
android:pathData="M0,0 L320,0 L320,180 L0,180 Z" />
|
||||
<!-- Subtle teal glow top-left -->
|
||||
<path
|
||||
android:fillColor="#1A64ffda"
|
||||
android:pathData="M0,0 L160,0 L160,90 L0,90 Z" />
|
||||
<!-- Subtle purple glow bottom-right -->
|
||||
<path
|
||||
android:fillColor="#15bb86fc"
|
||||
android:pathData="M160,90 L320,90 L320,180 L160,180 Z" />
|
||||
<!-- TV body, centered -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M88,56 L196,56 Q204,56 204,64 L204,116 Q204,124 196,124 L88,124 Q80,124 80,116 L80,64 Q80,56 88,56 Z" />
|
||||
<!-- TV screen -->
|
||||
<path
|
||||
android:fillColor="#161b22"
|
||||
android:pathData="M92,60 L192,60 Q196,60 196,64 L196,116 Q196,120 192,120 L92,120 Q88,120 88,116 L88,64 Q88,60 92,60 Z" />
|
||||
<!-- LED glow strips -->
|
||||
<path
|
||||
android:fillColor="#64ffda"
|
||||
android:fillAlpha="0.8"
|
||||
android:pathData="M94,50 L190,50 L190,54 L94,54 Z" />
|
||||
<path
|
||||
android:fillColor="#bb86fc"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M72,62 L76,62 L76,118 L72,118 Z" />
|
||||
<path
|
||||
android:fillColor="#ff6b6b"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M208,62 L212,62 L212,118 L208,118 Z" />
|
||||
<path
|
||||
android:fillColor="#ffd93d"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M94,126 L190,126 L190,130 L94,130 Z" />
|
||||
<!-- Wordmark "LedGrab" — drawn as paths so we don't depend on the
|
||||
system font cache being warm at TV launch. -->
|
||||
<!-- L -->
|
||||
<path android:fillColor="#64ffda"
|
||||
android:pathData="M222,72 L228,72 L228,100 L240,100 L240,106 L222,106 Z" />
|
||||
<!-- e -->
|
||||
<path android:fillColor="#e6edf3"
|
||||
android:pathData="M244,82 L260,82 Q264,82 264,86 L264,94 L250,94 L250,100 L262,100 L262,106 L246,106 Q244,106 244,104 Z M250,86 L250,90 L258,90 L258,86 Z" />
|
||||
<!-- d -->
|
||||
<path android:fillColor="#e6edf3"
|
||||
android:pathData="M266,72 L272,72 L272,82 L284,82 Q286,82 286,84 L286,106 L268,106 Q266,106 266,104 Z M272,88 L272,100 L280,100 L280,88 Z" />
|
||||
</vector>
|
||||
@@ -1,5 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Static fallback for the status dot. The animated version
|
||||
(animated_status_dot.xml) is used at runtime; this is what
|
||||
XML rendering tools show in the editor. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/green_status" />
|
||||
<size android:width="18dp" android:height="18dp" />
|
||||
</shape>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Monochrome status-bar icon. Android requires white-on-transparent for
|
||||
notification icons since API 21 - reusing the colored launcher would
|
||||
render as a gray blob. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFFFF">
|
||||
<!-- TV body -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M5,7 L19,7 Q20,7 20,8 L20,16 Q20,17 19,17 L5,17 Q4,17 4,16 L4,8 Q4,7 5,7 Z M5.5,8.5 L5.5,15.5 L18.5,15.5 L18.5,8.5 Z" />
|
||||
<!-- TV stand -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M10,17 L10,18.5 L14,18.5 L14,17 Z M9,19 L15,19 L15,20 L9,20 Z" />
|
||||
<!-- LED glow strips around the TV (bright dots) -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M6,5.5 L18,5.5 L18,6.5 L6,6.5 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Splash screen icon (API 31+ uses a 1:1 vector inside a 240dp circle).
|
||||
The SplashScreen API masks this with a circle automatically. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="240dp"
|
||||
android:height="240dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- TV body -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M26,32 L82,32 Q86,32 86,36 L86,68 Q86,72 82,72 L26,72 Q22,72 22,68 L22,36 Q22,32 26,32 Z" />
|
||||
<!-- TV screen -->
|
||||
<path
|
||||
android:fillColor="#161b22"
|
||||
android:pathData="M28,35 L80,35 Q82,35 82,37 L82,66 Q82,68 80,68 L28,68 Q26,68 26,66 L26,37 Q26,35 28,35 Z" />
|
||||
<!-- LED glow strips, brighter on splash for impact -->
|
||||
<path
|
||||
android:fillColor="#64ffda"
|
||||
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
|
||||
<path
|
||||
android:fillColor="#bb86fc"
|
||||
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
|
||||
<path
|
||||
android:fillColor="#ff6b6b"
|
||||
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
|
||||
<path
|
||||
android:fillColor="#ffd93d"
|
||||
android:pathData="M30,74 L78,74 L78,76 L30,76 Z" />
|
||||
<!-- TV stand -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M44,72 L44,78 L64,78 L64,72" />
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M38,78 L70,78 L70,80 L38,80 Z" />
|
||||
</vector>
|
||||
@@ -32,16 +32,28 @@
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.08"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:fontFamily="sans-serif-light" />
|
||||
android:fontFamily="sans-serif" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status_text"
|
||||
android:id="@+id/tagline_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/tagline"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="28sp"
|
||||
android:layout_marginBottom="64dp" />
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<!-- Transient status (root probing / permission denial). Always
|
||||
present so the layout doesn't reflow when text appears. -->
|
||||
<TextView
|
||||
android:id="@+id/status_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="32dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="20sp"
|
||||
android:layout_marginBottom="32dp"
|
||||
tools:text="Checking root access…" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/toggle_button"
|
||||
@@ -51,7 +63,38 @@
|
||||
android:text="@string/btn_start"
|
||||
android:textSize="22sp"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true" />
|
||||
android:focusableInTouchMode="true"
|
||||
android:nextFocusDown="@+id/autostart_check" />
|
||||
|
||||
<!-- Shown only while notification-listener access is missing. The D-pad
|
||||
focus chain is wired at runtime (wireStoppedFocusChain) because this
|
||||
button and the autostart checkbox are both conditionally visible. -->
|
||||
<Button
|
||||
android:id="@+id/grant_notification_button"
|
||||
style="@style/Widget.LedGrab.Button.Secondary"
|
||||
android:layout_width="320dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="@string/btn_grant_notification_access"
|
||||
android:textSize="18sp"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- Shown only while Usage Access is missing (needed by the foreground-app
|
||||
automation rule). Like the grant-notification button, its D-pad focus
|
||||
chain is wired at runtime (wireStoppedFocusChain). -->
|
||||
<Button
|
||||
android:id="@+id/grant_usage_access_button"
|
||||
style="@style/Widget.LedGrab.Button.Secondary"
|
||||
android:layout_width="320dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="@string/btn_grant_usage_access"
|
||||
android:textSize="18sp"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true"
|
||||
android:visibility="gone" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/autostart_check"
|
||||
@@ -63,10 +106,11 @@
|
||||
android:textSize="20sp"
|
||||
android:buttonTint="@color/teal_accent"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true" />
|
||||
android:focusableInTouchMode="true"
|
||||
android:nextFocusUp="@id/toggle_button" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Version at bottom -->
|
||||
<!-- Version at bottom (always visible — looks polished on TV idle). -->
|
||||
<TextView
|
||||
android:id="@+id/version_text"
|
||||
android:layout_width="wrap_content"
|
||||
@@ -77,115 +121,13 @@
|
||||
android:textSize="18sp"
|
||||
tools:text="v0.1.0" />
|
||||
|
||||
<!-- RUNNING STATE -->
|
||||
<LinearLayout
|
||||
android:id="@+id/running_panel"
|
||||
<!-- RUNNING STATE — deferred-inflate via ViewStub so first paint is
|
||||
cheaper and the inflater doesn't measure two competing layouts. -->
|
||||
<ViewStub
|
||||
android:id="@+id/running_panel_stub"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android: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>
|
||||
android:inflatedId="@+id/running_panel"
|
||||
android:layout="@layout/panel_running"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- RUNNING STATE -->
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="120dp"
|
||||
android:paddingEnd="120dp"
|
||||
android:paddingTop="80dp"
|
||||
android:paddingBottom="80dp">
|
||||
|
||||
<!-- Left: status + URL + stop -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="start|center_vertical"
|
||||
android:paddingEnd="64dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="32dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/status_dot"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:background="@drawable/bg_status_dot"
|
||||
android:layout_marginEnd="16dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/status_running"
|
||||
android:textColor="@color/green_status"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.05" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/label_web_ui"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="22sp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/url_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/teal_accent"
|
||||
android:textSize="30sp"
|
||||
android:maxLines="1"
|
||||
android:textStyle="bold"
|
||||
android:background="@drawable/bg_url_chip"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingEnd="24dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:layout_marginBottom="56dp"
|
||||
tools:text="http://192.168.1.5:8080" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/stop_button_running"
|
||||
style="@style/Widget.LedGrab.Button.Secondary"
|
||||
android:layout_width="240dp"
|
||||
android:layout_height="64dp"
|
||||
android:text="@string/btn_stop"
|
||||
android:textSize="20sp"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true"
|
||||
android:nextFocusUp="@id/stop_button_running"
|
||||
android:nextFocusDown="@id/stop_button_running" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Right: QR code + fallback hint -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_qr_container"
|
||||
android:padding="20dp"
|
||||
android:layout_marginBottom="20dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/qr_image"
|
||||
android:layout_width="280dp"
|
||||
android:layout_height="280dp"
|
||||
android:contentDescription="@string/qr_description"
|
||||
android:scaleType="fitXY" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/scan_to_configure"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="22sp"
|
||||
android:gravity="center" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="280dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/scan_fallback_hint"
|
||||
android:textColor="@color/text_hint"
|
||||
android:textSize="14sp"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="6dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -3,12 +3,29 @@
|
||||
<string name="app_name">LedGrab</string>
|
||||
<string name="tagline">Фоновая подсветка для телевизора</string>
|
||||
<string name="btn_start">Начать захват</string>
|
||||
<string name="btn_starting">Запуск…</string>
|
||||
<string name="btn_stop">Стоп</string>
|
||||
<string name="status_running">Работает</string>
|
||||
<string name="status_checking_root">Проверка root-доступа…</string>
|
||||
<string name="status_permission_denied">Доступ запрещён — для захвата экрана требуется разрешение</string>
|
||||
<string name="status_no_network">Нет сети — подключите Wi-Fi или Ethernet</string>
|
||||
<string name="label_web_ui">Адрес веб-интерфейса</string>
|
||||
<string name="scan_to_configure">Сканируйте для настройки</string>
|
||||
<string name="scan_fallback_hint">или откройте этот адрес с любого устройства в сети</string>
|
||||
<string name="qr_description">QR-код для веб-интерфейса</string>
|
||||
<string name="version_prefix">v%1$s</string>
|
||||
<string name="autostart_label">Запускать при загрузке (только с root)</string>
|
||||
<string name="autostart_unavailable">Запуск при загрузке — недоступно (нужен root)</string>
|
||||
<string name="fatal_title">Не удалось запустить LedGrab</string>
|
||||
<string name="fatal_body_prefix">Ошибка инициализации Python:</string>
|
||||
<string name="fatal_copy_log">Скопировать журнал</string>
|
||||
<string name="fatal_show_details">Показать подробности</string>
|
||||
<string name="fatal_hide_details">Скрыть подробности</string>
|
||||
<string name="notification_channel_name">Захват LedGrab</string>
|
||||
<string name="notification_channel_description">Отображается, пока LedGrab захватывает экран.</string>
|
||||
<string name="notification_title">LedGrab работает</string>
|
||||
<string name="notification_text">Веб-интерфейс: %1$s</string>
|
||||
<string name="notification_listener_label">Захват уведомлений LedGrab</string>
|
||||
<string name="btn_grant_notification_access">Разрешить доступ к уведомлениям</string>
|
||||
<string name="btn_grant_usage_access">Разрешить доступ к статистике использования</string>
|
||||
</resources>
|
||||
|
||||
@@ -3,12 +3,29 @@
|
||||
<string name="app_name">LedGrab</string>
|
||||
<string name="tagline">电视氛围灯光</string>
|
||||
<string name="btn_start">开始捕获</string>
|
||||
<string name="btn_starting">正在启动…</string>
|
||||
<string name="btn_stop">停止</string>
|
||||
<string name="status_running">运行中</string>
|
||||
<string name="status_checking_root">正在检查 root 权限…</string>
|
||||
<string name="status_permission_denied">权限被拒绝 — 屏幕捕获需要授权</string>
|
||||
<string name="status_no_network">无网络 — 请连接 Wi-Fi 或以太网</string>
|
||||
<string name="label_web_ui">Web界面地址</string>
|
||||
<string name="scan_to_configure">扫码配置</string>
|
||||
<string name="scan_fallback_hint">或在同一网络的任何设备上访问上方网址</string>
|
||||
<string name="qr_description">Web界面二维码</string>
|
||||
<string name="version_prefix">v%1$s</string>
|
||||
<string name="autostart_label">开机自启(仅限 root)</string>
|
||||
<string name="autostart_unavailable">开机自启 — 不可用(需要 root)</string>
|
||||
<string name="fatal_title">LedGrab 启动失败</string>
|
||||
<string name="fatal_body_prefix">Python 运行时初始化失败:</string>
|
||||
<string name="fatal_copy_log">复制日志</string>
|
||||
<string name="fatal_show_details">显示详情</string>
|
||||
<string name="fatal_hide_details">隐藏详情</string>
|
||||
<string name="notification_channel_name">LedGrab 屏幕捕获</string>
|
||||
<string name="notification_channel_description">LedGrab 捕获屏幕时显示。</string>
|
||||
<string name="notification_title">LedGrab 运行中</string>
|
||||
<string name="notification_text">Web界面:%1$s</string>
|
||||
<string name="notification_listener_label">LedGrab 通知捕获</string>
|
||||
<string name="btn_grant_notification_access">授予通知访问权限</string>
|
||||
<string name="btn_grant_usage_access">授予使用情况访问权限</string>
|
||||
</resources>
|
||||
|
||||
@@ -3,12 +3,29 @@
|
||||
<string name="app_name">LedGrab</string>
|
||||
<string name="tagline">Ambient lighting for your TV</string>
|
||||
<string name="btn_start">Start Capture</string>
|
||||
<string name="btn_starting">Starting…</string>
|
||||
<string name="btn_stop">Stop</string>
|
||||
<string name="status_running">Running</string>
|
||||
<string name="status_checking_root">Checking root access…</string>
|
||||
<string name="status_permission_denied">Permission denied — screen capture requires authorization</string>
|
||||
<string name="status_no_network">No network — connect Wi-Fi or Ethernet</string>
|
||||
<string name="label_web_ui">Web UI address</string>
|
||||
<string name="scan_to_configure">Scan to configure</string>
|
||||
<string name="scan_fallback_hint">or visit the URL above on any device on this network</string>
|
||||
<string name="qr_description">QR code for web UI</string>
|
||||
<string name="version_prefix">v%1$s</string>
|
||||
<string name="autostart_label">Start on boot (root only)</string>
|
||||
<string name="autostart_unavailable">Start on boot — unavailable (root required)</string>
|
||||
<string name="fatal_title">LedGrab failed to start</string>
|
||||
<string name="fatal_body_prefix">Python runtime initialization failed:</string>
|
||||
<string name="fatal_copy_log">Copy log</string>
|
||||
<string name="fatal_show_details">Show details</string>
|
||||
<string name="fatal_hide_details">Hide details</string>
|
||||
<string name="notification_channel_name">LedGrab capture</string>
|
||||
<string name="notification_channel_description">Shows while LedGrab is capturing the screen.</string>
|
||||
<string name="notification_title">LedGrab Running</string>
|
||||
<string name="notification_text">Web UI: %1$s</string>
|
||||
<string name="notification_listener_label">LedGrab notification capture</string>
|
||||
<string name="btn_grant_notification_access">Grant notification access</string>
|
||||
<string name="btn_grant_usage_access">Grant usage access</string>
|
||||
</resources>
|
||||
|
||||
@@ -12,6 +12,16 @@
|
||||
<item name="android:colorControlActivated">@color/teal_accent</item>
|
||||
</style>
|
||||
|
||||
<!-- Splash screen theme. Compatible across API levels via the
|
||||
androidx.core:core-splashscreen library. On API 31+ the system
|
||||
splash uses the foreground icon; on older versions the launch
|
||||
theme just paints the navy background, which is harmless. -->
|
||||
<style name="Theme.LedGrab.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/bg_navy</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.LedGrab</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.LedGrab.Button.Primary" parent="@android:style/Widget.Button">
|
||||
<item name="android:background">@drawable/bg_button_primary</item>
|
||||
<item name="android:textColor">@color/bg_navy</item>
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
<?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+.
|
||||
LedGrab is a LAN-only app:
|
||||
- Inbound: web UI / API on the device (HTTP, port 8080)
|
||||
- Outbound: WLED HTTP/UDP, Home Assistant, MQTT brokers, mDNS
|
||||
|
||||
All of these are plaintext on the local network. Android's network
|
||||
security config doesn't support CIDR allowlists, so we cannot
|
||||
restrict cleartext to RFC1918 ranges declaratively — we have to
|
||||
permit cleartext base-wide.
|
||||
|
||||
Defence-in-depth that ACTUALLY mitigates this:
|
||||
1. Inbound: the FastAPI server in this app rejects non-loopback
|
||||
requests when no API key is configured (see ledgrab.api.auth).
|
||||
The Android launcher auto-generates an API key on first run
|
||||
(see ApiKeyManager.kt) and injects it via the
|
||||
LEDGRAB_AUTH__API_KEYS env var before uvicorn starts. The
|
||||
user's phone receives the key by scanning the QR, which
|
||||
embeds the key as a URL fragment (never logged server-side).
|
||||
2. Outbound: targets are validated by net_classify in the Python
|
||||
layer (LAN-only HTTP, SSRF-safe).
|
||||
|
||||
DO NOT remove the cleartext permission without first migrating
|
||||
every LAN peer to HTTPS — most WLED firmware, mDNS, and the LAN
|
||||
HTTP server itself rely on this flag.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Cross-compile pydantic-core for Android across all three ABIs:
|
||||
# arm64-v8a (primary — real TV hardware)
|
||||
# Cross-compile pydantic-core for Android across all supported ABIs:
|
||||
# arm64-v8a (primary — modern TV hardware)
|
||||
# x86_64 (modern emulators)
|
||||
# x86 (legacy emulators)
|
||||
# armeabi-v7a (32-bit ARMv7 — older cheap TV boxes like X96 mini, MeCool)
|
||||
#
|
||||
# Outputs wheels into android/wheels/. Wheels are linked against the real
|
||||
# libpython3.11.so shipped by Chaquopy (stub .so does NOT work — see
|
||||
# memory/project_android_app.md for the incident notes).
|
||||
#
|
||||
# Prerequisites (on host):
|
||||
# - Rust + cargo (rustup) with targets: aarch64/x86_64/i686-linux-android
|
||||
# - Rust + cargo (rustup) with targets:
|
||||
# aarch64/x86_64/i686/armv7a-linux-android(eabi)
|
||||
# - Android NDK (ANDROID_NDK_HOME, or installed at Sdk/ndk/*)
|
||||
# - Python 3.11 (matches Chaquopy's embedded version)
|
||||
# - maturin (pip install maturin)
|
||||
@@ -19,9 +21,10 @@
|
||||
# core dependency version changes.
|
||||
#
|
||||
# Usage:
|
||||
# ./build-pydantic-core.sh # build all three ABIs
|
||||
# ./build-pydantic-core.sh # build all 4 ABIs
|
||||
# ./build-pydantic-core.sh arm64 # build a single ABI
|
||||
# ./build-pydantic-core.sh arm64 x86_64 # build a subset
|
||||
# ./build-pydantic-core.sh armv7 # 32-bit ARM only
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
@@ -94,18 +97,20 @@ ABI_TABLE=(
|
||||
"arm64 aarch64-linux-android aarch64-linux-android${API_LEVEL} cross-sysconfig"
|
||||
"x86_64 x86_64-linux-android x86_64-linux-android${API_LEVEL} cross-sysconfig-x86_64"
|
||||
"x86 i686-linux-android i686-linux-android${API_LEVEL} cross-sysconfig-x86"
|
||||
"armv7 armv7-linux-androideabi armv7a-linux-androideabi${API_LEVEL} cross-sysconfig-armv7"
|
||||
)
|
||||
|
||||
declare -A ABI_TAG_MAP=(
|
||||
[arm64]="arm64_v8a"
|
||||
[x86_64]="x86_64"
|
||||
[x86]="x86"
|
||||
[armv7]="armeabi_v7a"
|
||||
)
|
||||
|
||||
# ── Select which ABIs to build ──────────────────────────────────────
|
||||
SELECTED=("$@")
|
||||
if [ ${#SELECTED[@]} -eq 0 ]; then
|
||||
SELECTED=(arm64 x86_64 x86)
|
||||
SELECTED=(arm64 x86_64 x86 armv7)
|
||||
fi
|
||||
|
||||
# ── Ensure rust targets are installed ───────────────────────────────
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Generate LedGrab app icon assets.
|
||||
|
||||
Concept: "Spectrum Aperture" — a rounded-square frame (the screen/display)
|
||||
traced by a continuous RGB color-wheel stroke (the bias-light LED strip),
|
||||
on a near-black canvas with a soft chromatic bloom behind it.
|
||||
|
||||
Outputs:
|
||||
server/src/ledgrab/static/icons/icon-512.png (standard, opaque vignette bg)
|
||||
server/src/ledgrab/static/icons/icon-192.png (downscale of 512)
|
||||
server/src/ledgrab/static/icons/icon-512-maskable.png (safe-area padded, opaque)
|
||||
server/src/ledgrab/static/icons/icon-tray.png (256, transparent bg, frame + glow)
|
||||
server/src/ledgrab/static/icons/icon.ico (16/24/32/48/64/128/256)
|
||||
|
||||
Run from repo root:
|
||||
py -3.13 build/generate_icon.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import colorsys
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
|
||||
# ── Tunables ────────────────────────────────────────────────────────────
|
||||
SUPERSAMPLE = 4 # render at 4x and downsample for crispness
|
||||
BASE = 1024 # logical canvas size
|
||||
HQ = BASE * SUPERSAMPLE # render canvas
|
||||
|
||||
BG_TOP = (12, 14, 22) # near-black, faint cool tint
|
||||
BG_BOTTOM = (6, 7, 12) # darker at edges (vignette feel)
|
||||
|
||||
FRAME_INSET = 0.18 # margin from canvas edge to frame (fraction)
|
||||
FRAME_RADIUS = 0.22 # corner radius (fraction of frame side)
|
||||
FRAME_STROKE = 0.085 # stroke width (fraction of canvas)
|
||||
BLOOM_OPACITY = 0.62 # outer bloom strength (0–1)
|
||||
INNER_GLOW_OPACITY = 0.38 # inner chromatic reflection strength
|
||||
|
||||
# Hue rotation offset so red sits at the top
|
||||
HUE_OFFSET = -90.0 # degrees (negative = counter-clockwise shift)
|
||||
|
||||
|
||||
def lerp(a: float, b: float, t: float) -> float:
|
||||
return a + (b - a) * t
|
||||
|
||||
|
||||
def hue_to_rgb(hue_deg: float) -> tuple[int, int, int]:
|
||||
"""Bright, slightly desaturated spectral color (LED-like)."""
|
||||
h = (hue_deg % 360) / 360.0
|
||||
r, g, b = colorsys.hls_to_rgb(h, 0.58, 0.92)
|
||||
return int(r * 255), int(g * 255), int(b * 255)
|
||||
|
||||
|
||||
def vignette_background(size: int) -> Image.Image:
|
||||
"""Dark canvas with a soft radial vignette + faint scanline noise."""
|
||||
img = Image.new("RGB", (size, size), BG_TOP)
|
||||
px = img.load()
|
||||
cx, cy = size / 2, size / 2
|
||||
max_r = math.hypot(cx, cy)
|
||||
for y in range(size):
|
||||
for x in range(size):
|
||||
d = math.hypot(x - cx, y - cy) / max_r
|
||||
t = min(1.0, d**1.6)
|
||||
px[x, y] = (
|
||||
int(lerp(BG_TOP[0], BG_BOTTOM[0], t)),
|
||||
int(lerp(BG_TOP[1], BG_BOTTOM[1], t)),
|
||||
int(lerp(BG_TOP[2], BG_BOTTOM[2], t)),
|
||||
)
|
||||
return img
|
||||
|
||||
|
||||
def draw_chromatic_bloom(size: int) -> Image.Image:
|
||||
"""Soft, large chromatic glow behind the frame — the bias-light effect."""
|
||||
layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
|
||||
cx, cy = size / 2, size / 2
|
||||
radius = size * 0.36
|
||||
blob_r = int(size * 0.30)
|
||||
n_blobs = 24
|
||||
|
||||
for i in range(n_blobs):
|
||||
a = i / n_blobs * 360.0
|
||||
bx = cx + math.cos(math.radians(a - 90)) * radius
|
||||
by = cy + math.sin(math.radians(a - 90)) * radius
|
||||
r, g, b = hue_to_rgb(a + HUE_OFFSET)
|
||||
alpha = int(255 * BLOOM_OPACITY * 0.55)
|
||||
draw.ellipse(
|
||||
(bx - blob_r, by - blob_r, bx + blob_r, by + blob_r),
|
||||
fill=(r, g, b, alpha),
|
||||
)
|
||||
|
||||
# Heavy blur → continuous, dreamy halo
|
||||
layer = layer.filter(ImageFilter.GaussianBlur(radius=size * 0.10))
|
||||
return layer
|
||||
|
||||
|
||||
def rounded_rect_mask(size: int, inset: int, radius: int, stroke: int) -> Image.Image:
|
||||
"""L-mode mask of a rounded-rect ring (the frame stroke region)."""
|
||||
mask = Image.new("L", (size, size), 0)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
box_outer = (inset, inset, size - inset, size - inset)
|
||||
box_inner = (
|
||||
inset + stroke,
|
||||
inset + stroke,
|
||||
size - inset - stroke,
|
||||
size - inset - stroke,
|
||||
)
|
||||
r_outer = radius
|
||||
r_inner = max(0, radius - stroke)
|
||||
draw.rounded_rectangle(box_outer, radius=r_outer, fill=255)
|
||||
draw.rounded_rectangle(box_inner, radius=r_inner, fill=0)
|
||||
return mask
|
||||
|
||||
|
||||
def draw_spectrum_frame(size: int) -> Image.Image:
|
||||
"""Draw the rounded-square frame stroke filled with a hue-rotation gradient.
|
||||
|
||||
Strategy: paint a full-canvas angular hue gradient (centered), then
|
||||
clip it with the rounded-ring mask. This guarantees a continuous,
|
||||
seam-free color flow around the entire frame.
|
||||
"""
|
||||
cx, cy = size / 2, size / 2
|
||||
|
||||
gradient = Image.new("RGB", (size, size), (0, 0, 0))
|
||||
gpx = gradient.load()
|
||||
for y in range(size):
|
||||
dy = y - cy
|
||||
for x in range(size):
|
||||
dx = x - cx
|
||||
ang = math.degrees(math.atan2(dy, dx)) + 90.0 # 0° = top
|
||||
r, g, b = hue_to_rgb(ang + HUE_OFFSET)
|
||||
gpx[x, y] = (r, g, b)
|
||||
|
||||
inset = int(size * FRAME_INSET)
|
||||
frame_side = size - 2 * inset
|
||||
stroke = int(size * FRAME_STROKE)
|
||||
radius = int(frame_side * FRAME_RADIUS)
|
||||
|
||||
mask = rounded_rect_mask(size, inset, radius, stroke)
|
||||
|
||||
out = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
out.paste(gradient, (0, 0), mask)
|
||||
return out
|
||||
|
||||
|
||||
def draw_inner_screen(size: int) -> Image.Image:
|
||||
"""Subtle dark rounded square inside the frame, with faint chromatic
|
||||
inner reflection along the edges — like a screen catching ambient light."""
|
||||
inset = int(size * FRAME_INSET)
|
||||
stroke = int(size * FRAME_STROKE)
|
||||
frame_side = size - 2 * inset
|
||||
radius = int(frame_side * FRAME_RADIUS)
|
||||
|
||||
pad = int(stroke * 0.35)
|
||||
box = (
|
||||
inset + stroke + pad,
|
||||
inset + stroke + pad,
|
||||
size - inset - stroke - pad,
|
||||
size - inset - stroke - pad,
|
||||
)
|
||||
r_inner = max(0, radius - stroke - pad)
|
||||
|
||||
layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
# Dark fill, very slight cool tint
|
||||
draw.rounded_rectangle(box, radius=r_inner, fill=(10, 12, 18, 255))
|
||||
|
||||
# Inner chromatic glow: same spectrum, very soft, clipped to the screen
|
||||
bloom = draw_chromatic_bloom(size)
|
||||
screen_mask = Image.new("L", (size, size), 0)
|
||||
ImageDraw.Draw(screen_mask).rounded_rectangle(box, radius=r_inner, fill=255)
|
||||
|
||||
bloom_alpha = bloom.split()[-1].point(lambda v: int(v * INNER_GLOW_OPACITY))
|
||||
bloom.putalpha(bloom_alpha)
|
||||
|
||||
masked_bloom = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
masked_bloom.paste(bloom, (0, 0), screen_mask)
|
||||
layer.alpha_composite(masked_bloom)
|
||||
|
||||
# Faint highlight glint top-left
|
||||
glint = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
gdraw = ImageDraw.Draw(glint)
|
||||
glint_box = (
|
||||
box[0] + int(frame_side * 0.04),
|
||||
box[1] + int(frame_side * 0.04),
|
||||
box[0] + int(frame_side * 0.42),
|
||||
box[1] + int(frame_side * 0.18),
|
||||
)
|
||||
gdraw.rounded_rectangle(glint_box, radius=int(frame_side * 0.05), fill=(255, 255, 255, 22))
|
||||
glint = glint.filter(ImageFilter.GaussianBlur(radius=size * 0.012))
|
||||
masked_glint = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
masked_glint.paste(glint, (0, 0), screen_mask)
|
||||
layer.alpha_composite(masked_glint)
|
||||
|
||||
return layer
|
||||
|
||||
|
||||
def add_outer_frame_glow(frame_rgba: Image.Image) -> Image.Image:
|
||||
"""Take the spectrum frame and produce a blurred, brightened copy for glow."""
|
||||
glow = frame_rgba.copy()
|
||||
# Slightly inflate brightness for glow
|
||||
r, g, b, a = glow.split()
|
||||
glow = Image.merge("RGBA", (r, g, b, a.point(lambda v: min(255, int(v * 0.85)))))
|
||||
glow = glow.filter(ImageFilter.GaussianBlur(radius=glow.width * 0.025))
|
||||
return glow
|
||||
|
||||
|
||||
def render_tray(size: int) -> Image.Image:
|
||||
"""Render a tray-optimised icon: transparent background, bolder frame,
|
||||
tight outer glow. Designed to read clearly at 16–32 px on top of any
|
||||
taskbar color."""
|
||||
hq = size * SUPERSAMPLE
|
||||
|
||||
# Pull the frame inward a touch and beef up the stroke so it reads at 16 px.
|
||||
global FRAME_INSET, FRAME_STROKE
|
||||
saved_inset, saved_stroke = FRAME_INSET, FRAME_STROKE
|
||||
FRAME_INSET = 0.13
|
||||
FRAME_STROKE = 0.115
|
||||
try:
|
||||
frame = draw_spectrum_frame(hq)
|
||||
finally:
|
||||
FRAME_INSET, FRAME_STROKE = saved_inset, saved_stroke
|
||||
|
||||
# Tight, bright glow that doesn't bleed past the tray cell.
|
||||
glow = frame.copy()
|
||||
r, g, b, a = glow.split()
|
||||
glow = Image.merge("RGBA", (r, g, b, a.point(lambda v: min(255, int(v * 0.95)))))
|
||||
glow = glow.filter(ImageFilter.GaussianBlur(radius=hq * 0.012))
|
||||
|
||||
canvas = Image.new("RGBA", (hq, hq), (0, 0, 0, 0))
|
||||
canvas.alpha_composite(glow)
|
||||
canvas.alpha_composite(frame)
|
||||
|
||||
return canvas.resize((size, size), Image.LANCZOS)
|
||||
|
||||
|
||||
def render(size: int, *, maskable: bool = False) -> Image.Image:
|
||||
"""Render the full icon at the given size."""
|
||||
hq = size * SUPERSAMPLE
|
||||
|
||||
if maskable:
|
||||
# Maskable: pad inward so the entire icon survives a circular crop.
|
||||
# We render the standard composition at 80% of canvas size, centered.
|
||||
bg = Image.new("RGB", (hq, hq), BG_BOTTOM).convert("RGBA")
|
||||
bg.paste(vignette_background(hq), (0, 0))
|
||||
|
||||
inner = render(size, maskable=False).resize((int(hq * 0.78), int(hq * 0.78)), Image.LANCZOS)
|
||||
# Strip the bg from the inner render: composite the spectrum
|
||||
# parts on top of our maskable background.
|
||||
ox = (hq - inner.width) // 2
|
||||
oy = (hq - inner.height) // 2
|
||||
bg.alpha_composite(inner, (ox, oy))
|
||||
return bg.resize((size, size), Image.LANCZOS)
|
||||
|
||||
bg = vignette_background(hq).convert("RGBA")
|
||||
bloom = draw_chromatic_bloom(hq)
|
||||
frame = draw_spectrum_frame(hq)
|
||||
frame_glow = add_outer_frame_glow(frame)
|
||||
inner_screen = draw_inner_screen(hq)
|
||||
|
||||
# Composite order: bg → bloom → frame_glow → inner_screen → frame
|
||||
canvas = Image.new("RGBA", (hq, hq), (0, 0, 0, 0))
|
||||
canvas.alpha_composite(bg)
|
||||
canvas.alpha_composite(bloom)
|
||||
canvas.alpha_composite(frame_glow)
|
||||
canvas.alpha_composite(inner_screen)
|
||||
canvas.alpha_composite(frame)
|
||||
|
||||
return canvas.resize((size, size), Image.LANCZOS)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
targets = [
|
||||
repo_root / "server" / "src" / "ledgrab" / "static" / "icons",
|
||||
repo_root
|
||||
/ "android"
|
||||
/ "app"
|
||||
/ "build"
|
||||
/ "python"
|
||||
/ "sources"
|
||||
/ "debug"
|
||||
/ "ledgrab"
|
||||
/ "static"
|
||||
/ "icons",
|
||||
]
|
||||
|
||||
print("Rendering 1024 master...")
|
||||
master = render(1024, maskable=False)
|
||||
|
||||
print("Rendering maskable 1024 master...")
|
||||
maskable_master = render(1024, maskable=True)
|
||||
|
||||
print("Rendering tray 512 master (transparent bg)...")
|
||||
tray_master = render_tray(512)
|
||||
|
||||
for icons_dir in targets:
|
||||
if not icons_dir.exists():
|
||||
print(f" skip (missing): {icons_dir}")
|
||||
continue
|
||||
|
||||
out_512 = icons_dir / "icon-512.png"
|
||||
out_192 = icons_dir / "icon-192.png"
|
||||
out_mask = icons_dir / "icon-512-maskable.png"
|
||||
out_tray = icons_dir / "icon-tray.png"
|
||||
out_ico = icons_dir / "icon.ico"
|
||||
|
||||
master.resize((512, 512), Image.LANCZOS).save(out_512, "PNG", optimize=True)
|
||||
master.resize((192, 192), Image.LANCZOS).save(out_192, "PNG", optimize=True)
|
||||
maskable_master.resize((512, 512), Image.LANCZOS).save(out_mask, "PNG", optimize=True)
|
||||
tray_master.save(out_tray, "PNG", optimize=True)
|
||||
|
||||
# Pre-resize each frame from the 1024 master for maximum crispness.
|
||||
# Pass them via the `sizes` arg so Pillow embeds every variant.
|
||||
ico_sizes = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]
|
||||
# Use the tray (transparent-bg) variant for ICO frames so the file/
|
||||
# taskbar icon doesn't show a dark tile against light backgrounds.
|
||||
ico_source = tray_master.resize((256, 256), Image.LANCZOS)
|
||||
ico_source.save(out_ico, format="ICO", sizes=ico_sizes)
|
||||
|
||||
print(f" wrote: {icons_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+7
-3
@@ -56,9 +56,10 @@ SetCompressor /SOLID lzma
|
||||
; ── Functions ─────────────────────────────────────────────
|
||||
|
||||
Function LaunchApp
|
||||
; Only launch the app — do NOT open the browser here. A manual launch (no
|
||||
; --autostart) makes the app open the WebUI itself once /health responds,
|
||||
; so opening the URL here too made the page appear twice.
|
||||
ExecShell "open" "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"'
|
||||
Sleep 2000
|
||||
ExecShell "open" "http://localhost:8080/"
|
||||
FunctionEnd
|
||||
|
||||
; Detect running instance before install (file lock check on python.exe)
|
||||
@@ -162,8 +163,11 @@ Section "Desktop shortcut" SecDesktop
|
||||
SectionEnd
|
||||
|
||||
Section "Start with Windows" SecAutostart
|
||||
; Pass --autostart so the VBS sets LEDGRAB_AUTOSTART=1 and the app suppresses
|
||||
; the browser auto-open on Windows login. Manual launches (desktop / start
|
||||
; menu) don't pass the arg, so they keep opening the WebUI tab.
|
||||
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}" --autostart' \
|
||||
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
|
||||
SectionEnd
|
||||
|
||||
|
||||
+7
-1
@@ -31,9 +31,15 @@ Creates the Gitea release with a description table listing all artifacts. **The
|
||||
- Produces: **`LedGrab-{tag}-linux-x64.tar.gz`**
|
||||
|
||||
### 4. `build-docker`
|
||||
- Plain `docker build` + `docker push` (no Buildx — TrueNAS runners lack nested networking)
|
||||
- Plain `docker build` + `docker push` for **amd64** (no Buildx — TrueNAS runners lack nested networking)
|
||||
- Registry: `{gitea_host}/{repo}:{tag}`
|
||||
- Tags: `v0.x.x`, `0.x.x`, and `latest` (stable only, not alpha/beta/rc)
|
||||
- **arm64 is best-effort** (Raspberry Pi / arm64 HAOS): a `continue-on-error` step
|
||||
cross-builds arm64 via **QEMU binfmt** (`tonistiigi/binfmt`) + `docker manifest`
|
||||
(NOT buildx — sidesteps the docker-container-driver networking limit) and folds
|
||||
amd64 + arm64 into multi-arch manifest lists under the same tags, plus
|
||||
`:{tag}-amd64` / `:{tag}-arm64` arch-suffixed tags. If the runner can't run
|
||||
privileged binfmt the step is skipped and the amd64 tags above remain valid.
|
||||
|
||||
## Build Scripts
|
||||
|
||||
|
||||
+721
-251
File diff suppressed because it is too large
Load Diff
+42
-1
@@ -54,7 +54,48 @@ When you attach a device, a default calibration is created:
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Calibration
|
||||
## Automatic Calibration
|
||||
|
||||
The easiest way to calibrate your strip is the **Auto-Calibrate** wizard, available directly
|
||||
from the calibration modal. No LED counting required — just answer three questions and tap four
|
||||
corners.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A **Color Strip Source** (not a device-only target) associated with the strip.
|
||||
- A **WLED device** connected and reachable by LedGrab.
|
||||
|
||||
### How to Start
|
||||
|
||||
1. Open the **Calibration** modal for your strip source (pencil icon → Calibration tab).
|
||||
2. Click the **Auto-calibrate** button in the modal footer.
|
||||
3. Follow the five-step wizard.
|
||||
|
||||
### Wizard Steps
|
||||
|
||||
| Step | What you do |
|
||||
| ---- | ----------- |
|
||||
| 1. Device | Select the WLED device that drives the strip. |
|
||||
| 2. Start corner | LED #0 lights up on your device. Tap the corner where you see it. |
|
||||
| 3. Direction | Sweep a few LEDs light up in sequence. Tap the direction they move. |
|
||||
| 4. Mark corners | Use the step buttons to sweep to each remaining corner, then tap **Mark corner**. Repeat for all 4 corners. |
|
||||
| 5. Preview & Save | Review the detected layout (start position, direction, LED counts per edge). Click **Save** to apply. |
|
||||
|
||||
### What Happens in the Background
|
||||
|
||||
- A calibration session takes exclusive control of the device for the duration of the wizard;
|
||||
any previously running effect is paused and automatically restored when the wizard exits
|
||||
(whether by saving, cancelling, or closing the modal).
|
||||
- The solved `CalibrationConfig` is written directly to the Color Strip Source via the existing
|
||||
PUT endpoint and takes effect immediately (no restart needed).
|
||||
|
||||
### Tips
|
||||
|
||||
- If LED #0 is hard to see, reduce ambient lighting briefly.
|
||||
- The wizard works in the browser — desktop and Android TV app both supported.
|
||||
- If you make a mistake in step 4, use **Step back** to re-mark the previous corner.
|
||||
|
||||
## Manual Calibration
|
||||
|
||||
### Step 1: Identify Your LED Layout
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,274 +0,0 @@
|
||||
# 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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 270 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 412 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 213 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 358 KiB |
File diff suppressed because it is too large
Load Diff
+24
-10
@@ -6,33 +6,47 @@
|
||||
- `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/storage/` — Data models (dataclasses) and SQLite-backed persistence stores (`BaseSqliteStore`)
|
||||
- `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)
|
||||
- `data/` — Runtime data: SQLite database (`ledgrab.db`) + assets. Relocate the root with `LEDGRAB_DATA_DIR`.
|
||||
|
||||
## Entity & Storage Pattern
|
||||
|
||||
Each entity follows: dataclass model (`storage/`) + JSON store (`storage/*_store.py`) + Pydantic schemas (`api/schemas/`) + routes (`api/routes/`).
|
||||
Each entity follows: dataclass model (`storage/`) + SQLite store (`storage/*_store.py`, subclassing `BaseSqliteStore`) + Pydantic schemas (`api/schemas/`) + routes (`api/routes/`).
|
||||
|
||||
Stores keep an in-memory write-through cache over a per-entity SQLite table (the legacy `BaseJsonStore` still exists for reference but new stores use `BaseSqliteStore`). Schema/data shape changes go through `storage/data_migrations.py` — migrations are idempotent and tracked in a dedicated `data_migrations` audit table, so they run safely on every startup. **When renaming or restructuring stored fields, add a migration there** (see the Data Migration Policy in the root `CLAUDE.md`).
|
||||
|
||||
## Authentication
|
||||
|
||||
Server uses API key authentication via Bearer token in `Authorization` header.
|
||||
API key authentication via Bearer token in the `Authorization` header (`Authorization: Bearer <key>`). WebSocket connections authenticate with a first-message handshake (`{"type":"auth","token":"<key>"}`). See `src/ledgrab/api/auth.py` for the canonical logic.
|
||||
|
||||
- Config: `config/default_config.yaml` under `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"`)
|
||||
- Config: `config/default_config.yaml` under `auth.api_keys`; env var `LEDGRAB_AUTH__API_KEYS`
|
||||
- When `api_keys` is **empty** (default): **loopback** requests (`127.0.0.1` / `::1` / `localhost`) are allowed anonymously, but **LAN / remote** requests are rejected with `401`. Auth is *not* fully open.
|
||||
- When `api_keys` is **set**: a valid Bearer token is required from every client (loopback included).
|
||||
- `require_authenticated()` rejects even loopback-anonymous callers on sensitive endpoints (e.g. backup download, secret reveal).
|
||||
|
||||
## Activity / Audit Log
|
||||
|
||||
Persistent, queryable audit log of meaningful actions (auth, device, entity CRUD, capture, system), surfaced in the WebUI (Activity tab + Dashboard widget + Settings retention panel).
|
||||
|
||||
- **Storage is NOT a `BaseSqliteStore`.** `storage/activity_log_repository.py` is a purpose-built repository over a dedicated indexed `activity_log` table (migration `002_add_activity_log`) — query-on-demand with **keyset pagination** (`seq` cursor), never load-all-into-memory. Don't route it through the entity-store pattern.
|
||||
- **Recording.** `core/activity_log/recorder.py` (`ActivityRecorder`) is best-effort (never raises into the audited action) and **thread-safe** (inline on the event loop; `loop.call_soon_threadsafe` from non-loop threads, e.g. zeroconf discovery). It persists the entry **and** fires an `activity_logged` realtime event. Actor comes from the `current_actor` `ContextVar` (set in `verify_api_key`), default `"system"`.
|
||||
- **Entity CRUD is auto-audited** via the `fire_entity_event()` choke point in `api/dependencies.py` — every create/update/delete already calls it. **Delete handlers must pass `entity_name`** (the entity is gone by record time). Non-entity events use explicit `recorder.record(...)` (get it via `get_activity_recorder()` DI or `get_module_recorder()` for engine/thread sites).
|
||||
- **Never log secrets.** API-key tokens are never recorded. Wrap any untrusted/attacker-controllable string (mDNS names, headers, user-authored names) with `sanitize_display()` (`core/activity_log/sanitize.py`) before it enters a `message`/`metadata` field. Per-IP throttle bounds auth-failure audit writes.
|
||||
- **Adding a new audited event:** pick a dotted `action` (e.g. `"thing.created"`), call the recorder; for it to render localized in the UI, add `activity_log.msg.<action>` to all three `static/locales/*.json` (the frontend `localizeMessage()` maps action→template; falls back to the server `message`). Entity-type labels live under `activity_log.entity_type.<type>`.
|
||||
- **Adding a new realtime event type** (`pm.fire_event({"type": ...})`): add it to `_ALLOWED_SERVER_EVENT_TYPES` in `static/js/core/events-ws.ts` AND keep `tests/test_events_ws_parity.py` green.
|
||||
- **Retention + API.** `core/activity_log/retention.py` prunes by `max_days` + `max_entries` (settings persisted via `db.set_setting("activity_log")`); the recorder's `enabled` flag is rehydrated from those settings on startup. REST in `api/routes/activity_log.py`: `GET /activity-log` (list, `AuthRequired`), `GET /export` (CSV/JSON stream — `require_authenticated`; chunked keyset so it never holds the DB lock across the stream; CSV formula-injection guarded), `GET|PUT /settings` (PUT is `require_authenticated`), `DELETE` (clear — `require_authenticated`, self-audited). The table is covered by the existing whole-DB backup (no `STORE_MAP` change needed).
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a new API endpoint
|
||||
|
||||
1. Create route file in `api/routes/`
|
||||
1. Create route file in `api/routes/` (define an `APIRouter(prefix="/api/v1/...")`)
|
||||
2. Define request/response schemas in `api/schemas/`
|
||||
3. Register the router in `main.py`
|
||||
3. Register the router in `api/__init__.py` (it aggregates every route module into the single `router` that `main.py` mounts)
|
||||
4. Restart the server
|
||||
5. Test via `/docs` (Swagger UI)
|
||||
|
||||
|
||||
@@ -6,17 +6,29 @@ server:
|
||||
# For LAN access, add your machine's IP, e.g. "http://192.168.1.100:8080"
|
||||
cors_origins:
|
||||
- "http://localhost:8080"
|
||||
- "http://192.168.2.100:8080"
|
||||
|
||||
auth:
|
||||
# API keys — required for any non-loopback (LAN) request.
|
||||
# When empty:
|
||||
# When empty (default):
|
||||
# - 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
|
||||
# To enable LAN access, uncomment the example below and replace the value
|
||||
# with a secret you generated yourself (e.g. `openssl rand -hex 32`).
|
||||
# Do NOT ship a hard-coded key here — a publicly-known token grants full
|
||||
# LAN access to anyone on the network.
|
||||
api_keys:
|
||||
dev: "development-key-change-in-production"
|
||||
default: "development-key-change-in-production"
|
||||
# api_keys:
|
||||
# my-client: "replace-with-output-of-openssl-rand-hex-32"
|
||||
|
||||
# Expose the interactive API docs (/docs, /redoc, /openapi.json) WITHOUT a
|
||||
# Bearer token so they can be opened directly in a browser. When true, this
|
||||
# applies to loopback AND LAN clients. Only the API *surface* (route paths +
|
||||
# parameter schemas) is exposed — calling an endpoint from Swagger still
|
||||
# requires the token via its "Authorize" button, and every other route stays
|
||||
# protected. Leave false unless you want browsable docs on your network.
|
||||
expose_docs: false
|
||||
|
||||
# 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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+48
@@ -14,6 +14,9 @@
|
||||
"marked": "^17.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/big-shoulders-display": "^5.2.5",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"esbuild": "^0.27.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
@@ -434,6 +437,33 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/big-shoulders-display": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/big-shoulders-display/-/big-shoulders-display-5.2.5.tgz",
|
||||
"integrity": "sha512-ZH2w9u6018xbSf8vPZ42P/KxpQHfIsKnxSnMtLFgwui1zIS05vzlijAWRcaRQoY2pXu4Z3SVa88OANsmq6mkvA==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/jetbrains-mono": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/manrope": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz",
|
||||
"integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
@@ -704,6 +734,24 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@fontsource-variable/big-shoulders-display": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/big-shoulders-display/-/big-shoulders-display-5.2.5.tgz",
|
||||
"integrity": "sha512-ZH2w9u6018xbSf8vPZ42P/KxpQHfIsKnxSnMtLFgwui1zIS05vzlijAWRcaRQoY2pXu4Z3SVa88OANsmq6mkvA==",
|
||||
"dev": true
|
||||
},
|
||||
"@fontsource-variable/jetbrains-mono": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
|
||||
"dev": true
|
||||
},
|
||||
"@fontsource-variable/manrope": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz",
|
||||
"integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==",
|
||||
"dev": true
|
||||
},
|
||||
"@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/big-shoulders-display": "^5.2.5",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/manrope": "^5.2.8",
|
||||
"esbuild": "^0.27.4",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ledgrab"
|
||||
version = "0.4.2"
|
||||
version = "0.9.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"}
|
||||
@@ -117,3 +117,11 @@ target-version = ['py311']
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
# E + F are ruff's defaults; UP007 + UP045 enforce PEP-604 `X | Y` and
|
||||
# `T | None` style so we don't drift back to the legacy `Union[X, Y]` /
|
||||
# `Optional[T]` imports the REVIEW_TODO mechanical sweep removed.
|
||||
# Recent ruff versions split the rule — UP007 covers `Union`, UP045
|
||||
# covers `Optional`.
|
||||
extend-select = ["UP007", "UP045"]
|
||||
|
||||
+78
-20
@@ -288,23 +288,72 @@ $pythonExe = $resolvedPython
|
||||
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"
|
||||
# Launch python.exe directly with no parent-handle inheritance. We used to
|
||||
# wrap it in `cmd /c python ... 1>log 2>err` so the parent powershell could
|
||||
# tail crash logs, but that left an empty cmd.exe window hanging around for
|
||||
# the full server lifetime (cmd had to live to hold the redirect handles).
|
||||
# Instead, let python claim its own console window — the user sees the live
|
||||
# server log there, and there's no spurious cmd window.
|
||||
#
|
||||
# Why WMI Win32_Process.Create rather than Start-Process or
|
||||
# [Diagnostics.Process]::Start? Both of those go through CreateProcess with
|
||||
# bInheritHandles=true, which leaks the parent shell's pipe handles into
|
||||
# the new Python process. When the caller is Git-Bash (`restart.ps1 |
|
||||
# tail -10`), the bash pipe then stays open for the full server lifetime,
|
||||
# hanging the bash invocation even after powershell exits. WMI's
|
||||
# Win32_Process.Create uses CreateProcess with bInheritHandles=FALSE.
|
||||
|
||||
$argList = @()
|
||||
$argList += $launchArgs
|
||||
$argList += @('-m', $Module)
|
||||
|
||||
# Quote each arg defensively in case a future caller adds whitespace.
|
||||
function Quote-CmdArg {
|
||||
param([string]$Arg)
|
||||
if ($Arg -match '[\s"]') {
|
||||
return '"' + ($Arg -replace '"', '\"') + '"'
|
||||
}
|
||||
return $Arg
|
||||
}
|
||||
$quotedArgs = ($argList | ForEach-Object { Quote-CmdArg $_ }) -join ' '
|
||||
$pyQ = Quote-CmdArg $pythonExe
|
||||
|
||||
$cmdLine = $pyQ + ' ' + $quotedArgs
|
||||
|
||||
# Win32_Process.Create starts detached with no parent-handle inheritance.
|
||||
# Returns @{ ProcessId; ReturnValue (0 = success) }.
|
||||
# Title sets the visible console-window title so the user can tell at a
|
||||
# glance which server the window belongs to (useful when running real +
|
||||
# demo side by side on different ports).
|
||||
$startupInfo = New-CimInstance -ClassName Win32_ProcessStartup `
|
||||
-ClientOnly `
|
||||
-Property @{ Title = "LedGrab - $Module (port $Port)" }
|
||||
$wmiResult = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
|
||||
CommandLine = $cmdLine
|
||||
CurrentDirectory = $ServerRoot
|
||||
ProcessStartupInformation = $startupInfo
|
||||
} -ErrorAction SilentlyContinue
|
||||
|
||||
if (-not $wmiResult -or $wmiResult.ReturnValue -ne 0) {
|
||||
Write-Warning "WMI Win32_Process.Create failed (ReturnValue=$($wmiResult.ReturnValue)); falling back to Start-Process"
|
||||
# Fallback path — Start-Process inherits parent handles, so a piped
|
||||
# caller may hang. Acceptable here because this branch only runs when
|
||||
# WMI itself is broken (very rare).
|
||||
$startedProc = Start-Process -FilePath $pythonExe `
|
||||
-ArgumentList $argList `
|
||||
-WorkingDirectory $ServerRoot `
|
||||
-WindowStyle Hidden `
|
||||
-RedirectStandardOutput $logPath `
|
||||
-RedirectStandardError $errPath `
|
||||
-PassThru
|
||||
$startedPid = $startedProc.Id
|
||||
-WorkingDirectory $ServerRoot -PassThru
|
||||
$startedPid = if ($startedProc) { $startedProc.Id } else { 0 }
|
||||
} else {
|
||||
$startedPid = [int]$wmiResult.ProcessId
|
||||
}
|
||||
|
||||
# Confirm the process is actually our server (defensive — WMI sometimes
|
||||
# returns a PID for a transient ancestor on heavily loaded boxes).
|
||||
Start-Sleep -Milliseconds 250
|
||||
if (-not (Get-Process -Id $startedPid -ErrorAction SilentlyContinue)) {
|
||||
$rescanned = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot | Select-Object -First 1
|
||||
if ($rescanned) { $startedPid = $rescanned.ProcessId } else { $startedPid = 0 }
|
||||
}
|
||||
|
||||
# ---- Poll readiness --------------------------------------------------------
|
||||
|
||||
@@ -316,28 +365,37 @@ $deadline = (Get-Date).AddSeconds($StartupTimeoutSec)
|
||||
$ready = $false
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
# Bail early if the process has already exited — something went wrong.
|
||||
if ($startedPid -gt 0) {
|
||||
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
|
||||
if (-not $proc) { break }
|
||||
if (-not $proc) {
|
||||
$rescanned = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot | Select-Object -First 1
|
||||
if ($rescanned) { $startedPid = $rescanned.ProcessId } else { break }
|
||||
}
|
||||
} else {
|
||||
$rescanned = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot | Select-Object -First 1
|
||||
if ($rescanned) { $startedPid = $rescanned.ProcessId }
|
||||
}
|
||||
if (Test-PortOpen -Port $Port) { $ready = $true; break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
|
||||
if ($ready) {
|
||||
if ($startedPid -gt 0) {
|
||||
Write-Info "Server ready on port $Port (PID $startedPid)"
|
||||
} else {
|
||||
Write-Info "Server ready on port $Port"
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($startedPid -gt 0) {
|
||||
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
|
||||
if (-not $proc) {
|
||||
Write-Warning "Server process $startedPid exited before binding port $Port"
|
||||
Write-Warning "Server process $startedPid exited before binding port $Port (check the server console window for the error)"
|
||||
} 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 " $_" }
|
||||
}
|
||||
} else {
|
||||
Write-Warning "Could not locate server process; port $Port did not bind within ${StartupTimeoutSec}s"
|
||||
}
|
||||
exit 1
|
||||
|
||||
@@ -10,6 +10,15 @@ Set procEnv = WshShell.Environment("Process")
|
||||
procEnv("PYTHONPATH") = appRoot & "\app\src"
|
||||
procEnv("LEDGRAB_CONFIG_PATH") = appRoot & "\app\config\default_config.yaml"
|
||||
|
||||
' If launched as Windows autostart (via the SMSTARTUP shortcut), suppress the
|
||||
' browser auto-open. Manual launches (desktop / start menu) pass no args.
|
||||
For Each arg In WScript.Arguments
|
||||
If arg = "--autostart" Then
|
||||
procEnv("LEDGRAB_AUTOSTART") = "1"
|
||||
Exit For
|
||||
End If
|
||||
Next
|
||||
|
||||
' Use embedded python.exe (NOT pythonw.exe) with WindowStyle=0.
|
||||
' Same pattern as the Media Server sibling app.
|
||||
embeddedPython = appRoot & "\python\python.exe"
|
||||
|
||||
@@ -1,16 +1,48 @@
|
||||
"""LED Grab - Ambient lighting based on screen content."""
|
||||
|
||||
from importlib.metadata import version, PackageNotFoundError
|
||||
from pathlib import Path
|
||||
|
||||
# Fallback version — kept in sync with pyproject.toml. MUST match the
|
||||
# version declared there on every release. The Windows installer build
|
||||
# (build/build-dist.ps1) also patches this literal to the resolved build
|
||||
# version, so any drift here is corrected for bundled distributions.
|
||||
# Used when the package isn't pip-installed (e.g. embedded via Chaquopy
|
||||
# on Android, where the source is included directly via source sets, or
|
||||
# in the Windows bundle where the installed dist-info is stripped).
|
||||
_FALLBACK_VERSION = "0.4.2"
|
||||
# Fallback version — patched at build time by build/build-dist.ps1 so the
|
||||
# bundled Windows distribution reports the release version (the installer
|
||||
# strips ledgrab-*.dist-info, so importlib.metadata fails there).
|
||||
# In dev (running from source without `pip install -e .`) and on Android
|
||||
# (Chaquopy embeds the source directly with no dist-info), we additionally
|
||||
# read pyproject.toml so the version is always correct without manual sync.
|
||||
_FALLBACK_VERSION = "0.8.1"
|
||||
|
||||
|
||||
def _read_pyproject_version() -> str | None:
|
||||
"""Read version from pyproject.toml (server/pyproject.toml relative to this file).
|
||||
|
||||
Returns None if the file is absent (typical for installed/bundled distributions
|
||||
where pyproject.toml isn't shipped) or unreadable.
|
||||
"""
|
||||
try:
|
||||
# __init__.py -> ledgrab/ -> src/ -> server/
|
||||
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
||||
if not pyproject.is_file():
|
||||
return None
|
||||
try:
|
||||
import tomllib # Python 3.11+
|
||||
except ImportError:
|
||||
return None
|
||||
with pyproject.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
v = data.get("project", {}).get("version")
|
||||
return v if isinstance(v, str) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# Prefer pyproject.toml when it sits next to the source (dev checkout). This
|
||||
# avoids stale `pip install -e .` dist-info pinning an older version after a
|
||||
# bump. When pyproject.toml isn't shipped (installed packages, Windows bundle,
|
||||
# Android), fall back to importlib.metadata, then the patched literal.
|
||||
_live = _read_pyproject_version()
|
||||
if _live:
|
||||
__version__ = _live
|
||||
else:
|
||||
try:
|
||||
__version__ = version("ledgrab")
|
||||
except PackageNotFoundError:
|
||||
|
||||
+132
-14
@@ -6,12 +6,15 @@ shows a system-tray icon with **Show UI** / **Exit** actions.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from urllib.error import URLError
|
||||
from urllib.request import urlopen
|
||||
|
||||
|
||||
def _fix_embedded_tcl_paths() -> None:
|
||||
@@ -36,15 +39,19 @@ _fix_embedded_tcl_paths()
|
||||
|
||||
import uvicorn # noqa: E402
|
||||
|
||||
from ledgrab.config import get_config # noqa: E402
|
||||
from ledgrab.config import Config, get_config # noqa: E402
|
||||
from ledgrab.server_ref import set_server, set_tray # noqa: E402
|
||||
from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT # noqa: E402
|
||||
from ledgrab.tray import PYSTRAY_AVAILABLE, TrayManager # noqa: E402
|
||||
from ledgrab.utils import setup_logging, get_logger # noqa: E402
|
||||
from ledgrab.utils.platform import is_windows # noqa: E402
|
||||
from ledgrab.utils.win_shutdown import WindowsShutdownGuard # noqa: E402
|
||||
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_ICON_PATH = Path(__file__).parent / "static" / "icons" / "icon-192.png"
|
||||
_ICON_PATH = Path(__file__).parent / "static" / "icons" / "icon-tray.png"
|
||||
_ICON_FALLBACK_PATH = Path(__file__).parent / "static" / "icons" / "icon-192.png"
|
||||
|
||||
|
||||
def _run_server(server: uvicorn.Server) -> None:
|
||||
@@ -54,9 +61,25 @@ def _run_server(server: uvicorn.Server) -> None:
|
||||
loop.run_until_complete(server.serve())
|
||||
|
||||
|
||||
def _open_browser(port: int, delay: float = 2.0) -> None:
|
||||
"""Open the UI in the default browser after a short delay."""
|
||||
time.sleep(delay)
|
||||
def _wait_for_server(port: int, timeout: float = 30.0, interval: float = 0.25) -> bool:
|
||||
"""Poll /health until the server responds or *timeout* seconds elapse."""
|
||||
url = f"http://localhost:{port}/health"
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with urlopen(url, timeout=1) as resp: # noqa: S310 - localhost only
|
||||
if 200 <= resp.status < 500:
|
||||
return True
|
||||
except (URLError, ConnectionError, OSError, TimeoutError):
|
||||
pass
|
||||
time.sleep(interval)
|
||||
return False
|
||||
|
||||
|
||||
def _open_browser(port: int) -> None:
|
||||
"""Open the UI in the default browser once the server is ready."""
|
||||
if not _wait_for_server(port):
|
||||
logger.warning("Server did not become ready in time; opening browser anyway")
|
||||
webbrowser.open(f"http://localhost:{port}")
|
||||
|
||||
|
||||
@@ -65,6 +88,16 @@ def _is_restart() -> bool:
|
||||
return os.environ.get("LEDGRAB_RESTART", "") == "1"
|
||||
|
||||
|
||||
def _is_autostart() -> bool:
|
||||
"""Detect if launched via the Windows autostart shortcut."""
|
||||
return os.environ.get("LEDGRAB_AUTOSTART", "") == "1"
|
||||
|
||||
|
||||
def _should_skip_browser() -> bool:
|
||||
"""Skip auto-opening the browser on restarts and on Windows login autostart."""
|
||||
return _is_restart() or _is_autostart()
|
||||
|
||||
|
||||
def _check_port(host: str, port: int) -> None:
|
||||
"""Exit with a clear message if the port is already in use."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
@@ -76,23 +109,46 @@ def _check_port(host: str, port: int) -> None:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
config = get_config()
|
||||
_check_port(config.server.host, config.server.port)
|
||||
def _build_server(config: Config) -> uvicorn.Server:
|
||||
"""Construct the uvicorn Server with a bounded graceful-shutdown timeout.
|
||||
|
||||
Extracted so the graceful-shutdown bound is unit-testable — leaving it
|
||||
unset (the uvicorn default of ``None``) is the regression that strands
|
||||
LED targets and prevents the process from exiting.
|
||||
"""
|
||||
uv_config = uvicorn.Config(
|
||||
"ledgrab.main:app",
|
||||
host=config.server.host,
|
||||
port=config.server.port,
|
||||
log_level=config.server.log_level.lower(),
|
||||
timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT,
|
||||
)
|
||||
server = uvicorn.Server(uv_config)
|
||||
return uvicorn.Server(uv_config)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
config = get_config()
|
||||
_check_port(config.server.host, config.server.port)
|
||||
|
||||
server = _build_server(config)
|
||||
set_server(server)
|
||||
|
||||
# Wire the OS-shutdown safety net. The lifespan in ``ledgrab.main`` signals
|
||||
# ``shutdown_complete`` once it has stopped targets and checkpointed the
|
||||
# DB; the Windows guard waits on that event before letting the OS finish
|
||||
# ending the session. Without this, the entire shutdown lifespan never
|
||||
# runs on PC reboot — devices stay on and the SQLite WAL is lost.
|
||||
guard = _install_os_shutdown_guard(server)
|
||||
|
||||
use_tray = PYSTRAY_AVAILABLE and (sys.platform == "win32" or _force_tray())
|
||||
|
||||
if use_tray:
|
||||
logger.info("Starting with system tray icon")
|
||||
# Install signal handlers BEFORE starting the uvicorn thread so a
|
||||
# SIGINT/SIGBREAK during startup still triggers a clean shutdown.
|
||||
# We do NOT install them on the no-tray path because uvicorn's
|
||||
# ``server.run()`` overwrites SIGINT/SIGTERM with its own handlers.
|
||||
_install_signal_handlers(server)
|
||||
|
||||
# Uvicorn in a background thread
|
||||
server_thread = threading.Thread(
|
||||
@@ -102,8 +158,8 @@ def main() -> None:
|
||||
)
|
||||
server_thread.start()
|
||||
|
||||
# Browser after a short delay (skip on restart — user already has a tab)
|
||||
if not _is_restart():
|
||||
# Browser after a short delay (skip on restart and on Windows login autostart)
|
||||
if not _should_skip_browser():
|
||||
threading.Thread(
|
||||
target=_open_browser,
|
||||
args=(config.server.port,),
|
||||
@@ -111,20 +167,31 @@ def main() -> None:
|
||||
).start()
|
||||
|
||||
# Tray on main thread (blocking)
|
||||
tray_icon = _ICON_PATH if _ICON_PATH.exists() else _ICON_FALLBACK_PATH
|
||||
tray = TrayManager(
|
||||
icon_path=_ICON_PATH,
|
||||
icon_path=tray_icon,
|
||||
port=config.server.port,
|
||||
on_exit=lambda: _request_shutdown(server),
|
||||
)
|
||||
set_tray(tray)
|
||||
tray.run()
|
||||
|
||||
# Tray exited — wait for server to finish its graceful shutdown
|
||||
server_thread.join(timeout=10)
|
||||
# Tray exited — wait for server to finish its graceful shutdown.
|
||||
# Budget: the graceful-shutdown wait (GRACEFUL_SHUTDOWN_TIMEOUT) runs
|
||||
# first, then the lifespan's own ~16 s shutdown (target restore + DB
|
||||
# checkpoint). Join longer than their sum so a slow disk doesn't get
|
||||
# the DB checkpoint cut short.
|
||||
server_thread.join(timeout=25)
|
||||
if guard is not None:
|
||||
guard.stop()
|
||||
else:
|
||||
if not PYSTRAY_AVAILABLE:
|
||||
logger.info("System tray not available (install pystray for tray support)")
|
||||
try:
|
||||
server.run()
|
||||
finally:
|
||||
if guard is not None:
|
||||
guard.stop()
|
||||
|
||||
|
||||
def _request_shutdown(server: uvicorn.Server) -> None:
|
||||
@@ -132,6 +199,57 @@ def _request_shutdown(server: uvicorn.Server) -> None:
|
||||
server.should_exit = True
|
||||
|
||||
|
||||
def _install_os_shutdown_guard(server: uvicorn.Server) -> "WindowsShutdownGuard | None":
|
||||
"""Install the OS-shutdown safety net (Windows only).
|
||||
|
||||
Returns the guard so the caller can ``stop()`` it on normal exit, or
|
||||
``None`` on platforms where no guard is needed.
|
||||
"""
|
||||
if not is_windows():
|
||||
return None
|
||||
|
||||
# ``shutdown_state`` is a leaf module — importing it does NOT pull in
|
||||
# ``ledgrab.main`` and its global stores. uvicorn loads ``main`` lazily
|
||||
# via the import string ``"ledgrab.main:app"`` once it starts serving.
|
||||
from ledgrab.shutdown_state import shutdown_complete
|
||||
|
||||
guard = WindowsShutdownGuard(
|
||||
on_shutdown=lambda: _request_shutdown(server),
|
||||
shutdown_complete=shutdown_complete,
|
||||
)
|
||||
if guard.start():
|
||||
logger.info("Windows shutdown guard installed")
|
||||
else:
|
||||
logger.warning("Windows shutdown guard failed to start")
|
||||
return guard
|
||||
|
||||
|
||||
def _install_signal_handlers(server: uvicorn.Server) -> None:
|
||||
"""Catch terminal/admin shutdown signals and trigger graceful exit.
|
||||
|
||||
Uvicorn already installs SIGINT/SIGTERM handlers when ``server.run()``
|
||||
is called on the main thread (the no-tray path). For the tray path,
|
||||
uvicorn runs on a background thread and skips signal installation, so
|
||||
we install our own here. SIGBREAK is Windows-specific and fires on
|
||||
Ctrl-Break and in some service-stop scenarios.
|
||||
"""
|
||||
|
||||
def _handler(signum, frame): # noqa: ANN001 - signal handler signature
|
||||
logger.warning("Signal %s received — requesting shutdown", signum)
|
||||
_request_shutdown(server)
|
||||
|
||||
candidates = ["SIGINT", "SIGTERM", "SIGBREAK"]
|
||||
for name in candidates:
|
||||
sig = getattr(signal, name, None)
|
||||
if sig is None:
|
||||
continue
|
||||
try:
|
||||
signal.signal(sig, _handler)
|
||||
except (ValueError, OSError) as e:
|
||||
# ValueError: not on main thread; OSError: signal not supported here.
|
||||
logger.debug("Could not install handler for %s: %s", name, e)
|
||||
|
||||
|
||||
def _force_tray() -> bool:
|
||||
"""Allow forcing tray on non-Windows via LEDGRAB_TRAY=1."""
|
||||
import os
|
||||
|
||||
@@ -6,16 +6,17 @@ inside an Android application. Sets up Android-specific paths
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
_server_thread: Optional[threading.Thread] = None
|
||||
_server: Optional[Any] = None # uvicorn.Server
|
||||
_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
_server_thread: threading.Thread | None = None
|
||||
_server: Any | None = None # uvicorn.Server
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
def start_server(data_dir: str, port: int = 8080) -> None:
|
||||
def start_server(data_dir: str, port: int = 8080, api_key: str | None = None) -> None:
|
||||
"""Start the LedGrab uvicorn server.
|
||||
|
||||
Called from Kotlin's ``PythonBridge.startServer()``. This function
|
||||
@@ -26,6 +27,11 @@ def start_server(data_dir: str, port: int = 8080) -> None:
|
||||
data_dir: Android app-private files directory
|
||||
(e.g. ``/data/data/com.ledgrab.android/files``).
|
||||
port: HTTP port for the web UI / API.
|
||||
api_key: Optional Bearer token to enable LAN auth. When set,
|
||||
published as ``LEDGRAB_AUTH__API_KEYS={"android":<key>}``
|
||||
so the server's auth gate accepts LAN requests carrying
|
||||
``Authorization: Bearer <key>``. When None, the server
|
||||
falls back to its default (loopback-only).
|
||||
"""
|
||||
# ── Configure paths before any LedGrab imports ──────────────
|
||||
os.makedirs(os.path.join(data_dir, "data"), exist_ok=True)
|
||||
@@ -41,6 +47,14 @@ def start_server(data_dir: str, port: int = 8080) -> None:
|
||||
os.environ["LEDGRAB_SERVER__HOST"] = "0.0.0.0"
|
||||
os.environ["LEDGRAB_SERVER__PORT"] = str(port)
|
||||
|
||||
# Provision LAN auth when the Kotlin launcher supplied a key. The
|
||||
# config layer (pydantic-settings) parses ``LEDGRAB_AUTH__API_KEYS``
|
||||
# as JSON when the value starts with `{`. We use a dict so the
|
||||
# rest of the codebase sees a labelled key just like the YAML
|
||||
# config form (api_keys: {android: ...}).
|
||||
if api_key:
|
||||
os.environ["LEDGRAB_AUTH__API_KEYS"] = json.dumps({"android": api_key})
|
||||
|
||||
# ── Now safe to import LedGrab ──────────────────────────────
|
||||
import uvicorn # noqa: E402
|
||||
|
||||
@@ -50,10 +64,27 @@ def start_server(data_dir: str, port: int = 8080) -> None:
|
||||
logger = get_logger(__name__)
|
||||
logger.info("LedGrab Android: starting server on port %d", port)
|
||||
logger.info("Data directory: %s", data_dir)
|
||||
if api_key:
|
||||
logger.info("LedGrab Android: API key auth enabled (label=android)")
|
||||
else:
|
||||
logger.warning("LedGrab Android: no API key — LAN requests will be rejected")
|
||||
|
||||
from ledgrab.config import get_config # noqa: E402
|
||||
|
||||
config = get_config()
|
||||
# Defensive: confirm the env var actually landed in the parsed config.
|
||||
# If pydantic-settings ever changes how it deserialises dict[str, str]
|
||||
# from env, the LAN auth would silently break (server would 401 every
|
||||
# phone scan). Logging the mismatch makes the failure mode obvious in
|
||||
# adb logcat.
|
||||
if api_key and config.auth.api_keys.get("android") != api_key:
|
||||
logger.error(
|
||||
"LedGrab Android: API key did NOT land in config — LAN auth will "
|
||||
"reject all requests. Check pydantic-settings dict parsing for "
|
||||
"LEDGRAB_AUTH__API_KEYS."
|
||||
)
|
||||
|
||||
from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT
|
||||
|
||||
uv_config = uvicorn.Config(
|
||||
"ledgrab.main:app",
|
||||
@@ -62,6 +93,9 @@ def start_server(data_dir: str, port: int = 8080) -> None:
|
||||
log_level=config.server.log_level.lower(),
|
||||
# No uvloop/httptools on Android — use pure-Python asyncio
|
||||
loop="asyncio",
|
||||
# Bound the graceful-shutdown wait so stop_server() can't hang forever
|
||||
# on a lingering WebView events WebSocket — see shutdown_state for why.
|
||||
timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT,
|
||||
)
|
||||
|
||||
global _server, _loop
|
||||
|
||||
@@ -18,6 +18,7 @@ from .routes.audio_templates import router as audio_templates_router
|
||||
from .routes.value_sources import router as value_sources_router
|
||||
from .routes.automations import router as automations_router
|
||||
from .routes.scene_presets import router as scene_presets_router
|
||||
from .routes.scene_playlists import router as scene_playlists_router
|
||||
from .routes.webhooks import router as webhooks_router
|
||||
from .routes.sync_clocks import router as sync_clocks_router
|
||||
from .routes.color_strip_processing import router as cspt_router
|
||||
@@ -27,10 +28,17 @@ from .routes.update import router as update_router
|
||||
from .routes.assets import router as assets_router
|
||||
from .routes.home_assistant import router as home_assistant_router
|
||||
from .routes.mqtt import router as mqtt_router
|
||||
from .routes.http_endpoints import router as http_endpoints_router
|
||||
from .routes.game_integration import router as game_integration_router
|
||||
from .routes.audio_processing_templates import router as audio_processing_templates_router
|
||||
from .routes.audio_filters import router as audio_filters_router
|
||||
from .routes.pattern_templates import router as pattern_templates_router
|
||||
from .routes.preferences import router as preferences_router
|
||||
from .routes.snapshot import router as snapshot_router
|
||||
from .routes.graph import router as graph_router
|
||||
from .routes.calibration import router as calibration_router
|
||||
from .routes.setup import router as setup_router
|
||||
from .routes.activity_log import router as activity_log_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(system_router)
|
||||
@@ -49,6 +57,7 @@ router.include_router(output_targets_router)
|
||||
router.include_router(output_targets_control_router)
|
||||
router.include_router(automations_router)
|
||||
router.include_router(scene_presets_router)
|
||||
router.include_router(scene_playlists_router)
|
||||
router.include_router(webhooks_router)
|
||||
router.include_router(sync_clocks_router)
|
||||
router.include_router(cspt_router)
|
||||
@@ -58,9 +67,16 @@ router.include_router(update_router)
|
||||
router.include_router(assets_router)
|
||||
router.include_router(home_assistant_router)
|
||||
router.include_router(mqtt_router)
|
||||
router.include_router(http_endpoints_router)
|
||||
router.include_router(game_integration_router)
|
||||
router.include_router(audio_processing_templates_router)
|
||||
router.include_router(audio_filters_router)
|
||||
router.include_router(pattern_templates_router)
|
||||
router.include_router(preferences_router)
|
||||
router.include_router(snapshot_router)
|
||||
router.include_router(graph_router)
|
||||
router.include_router(calibration_router)
|
||||
router.include_router(setup_router)
|
||||
router.include_router(activity_log_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
+277
-25
@@ -3,21 +3,152 @@
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from typing import Annotated
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, Security, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||
|
||||
from ledgrab.config import get_config
|
||||
from ledgrab.core.activity_log.context import current_actor
|
||||
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ── Auth-failure audit throttle (H3) ───────────────────────────────────────
|
||||
#
|
||||
# Unauthenticated callers can hammer any auth path; without a recording
|
||||
# throttle each attempt would write one SQLite row AND broadcast one WS event,
|
||||
# providing a cheap disk/broadcast amplification vector.
|
||||
#
|
||||
# Mitigation: record at most one ``auth.rejected`` audit entry per client IP
|
||||
# per _AUTH_RECORD_WINDOW seconds. The auth decision (401) is NEVER
|
||||
# suppressed — only the *audit recording* is de-duplicated.
|
||||
#
|
||||
# Memory safety: the throttle dict is capped at _AUTH_THROTTLE_HARD_CAP
|
||||
# entries. When the cap is exceeded the oldest-inserted IP is evicted in O(1)
|
||||
# so the dict stays bounded regardless of the number of distinct source IPs an
|
||||
# attacker can forge.
|
||||
#
|
||||
# Thread safety: the throttle dict is guarded by ``_auth_record_lock`` (mirrors
|
||||
# ``_auth_fail_lock`` in routes/game_integration) so the compound
|
||||
# read/evict/insert is atomic. The HTTP auth dependency runs on the event loop
|
||||
# (``verify_api_key`` is async), but ``_record_auth_failure`` is reached from
|
||||
# both the HTTP and WebSocket auth paths and must remain safe if ever called
|
||||
# from a background thread — the lock is uncontended on the loop, so it costs
|
||||
# nothing while preventing a KeyError / "dict changed size" from ever turning
|
||||
# an intended 401 into a 500.
|
||||
|
||||
_AUTH_RECORD_WINDOW: float = 10.0 # seconds — one record per IP per window
|
||||
_AUTH_THROTTLE_HARD_CAP: int = 512 # max IPs tracked simultaneously
|
||||
|
||||
# ip -> monotonic timestamp of last *recorded* auth.rejected entry.
|
||||
# OrderedDict so the oldest insertion can be evicted in O(1) via popitem.
|
||||
_auth_record_last: "OrderedDict[str, float]" = OrderedDict()
|
||||
_auth_record_lock = threading.Lock()
|
||||
|
||||
|
||||
def _should_record_auth_failure(client_ip: str) -> bool:
|
||||
"""Return True when an ``auth.rejected`` record should be written for *client_ip*.
|
||||
|
||||
Suppresses duplicates within _AUTH_RECORD_WINDOW seconds. Evicts the
|
||||
oldest entry when the dict exceeds _AUTH_THROTTLE_HARD_CAP to prevent
|
||||
unbounded memory growth under IP-spray attacks.
|
||||
|
||||
Thread-safe: the entire read/evict/insert is performed under
|
||||
``_auth_record_lock`` so concurrent threadpool workers cannot corrupt the
|
||||
dict or raise mid-mutation.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
with _auth_record_lock:
|
||||
last = _auth_record_last.get(client_ip)
|
||||
if last is not None and (now - last) < _AUTH_RECORD_WINDOW:
|
||||
return False # suppress: within the de-dup window
|
||||
|
||||
# Enforce hard cap before inserting: evict the oldest entry in O(1).
|
||||
if len(_auth_record_last) >= _AUTH_THROTTLE_HARD_CAP:
|
||||
_auth_record_last.popitem(last=False)
|
||||
|
||||
# Refresh recency: move/insert this IP to the most-recent end so the
|
||||
# popitem(last=False) above always drops a genuinely old entry.
|
||||
_auth_record_last[client_ip] = now
|
||||
_auth_record_last.move_to_end(client_ip)
|
||||
return True
|
||||
|
||||
|
||||
def _record_auth_failure(reason: str, client_host: str | None) -> None:
|
||||
"""Best-effort: record an auth failure audit entry (never raises).
|
||||
|
||||
SECURITY: the attempted token is NEVER passed here; only the reason and
|
||||
the caller's IP/label are recorded.
|
||||
|
||||
THROTTLE: at most one ``auth.rejected`` record is written per client IP
|
||||
per _AUTH_RECORD_WINDOW seconds to prevent disk/WS-broadcast amplification
|
||||
DoS. The 401 response is always returned regardless.
|
||||
|
||||
The whole body is wrapped so an audit-path failure can never convert an
|
||||
intended 401 into a 500 (honors the "never raises" contract).
|
||||
"""
|
||||
try:
|
||||
if not _should_record_auth_failure(client_host or "unknown"):
|
||||
return # throttled — drop duplicate recording for this IP/window
|
||||
|
||||
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||
|
||||
rec = get_module_recorder()
|
||||
if rec is None:
|
||||
return
|
||||
rec.record(
|
||||
category=ActivityCategory.AUTH,
|
||||
action="auth.rejected",
|
||||
severity=ActivitySeverity.WARNING,
|
||||
actor="anonymous",
|
||||
message=f"Authentication failed: {reason}",
|
||||
metadata={"reason": reason, "client": client_host or "unknown"},
|
||||
)
|
||||
except Exception as exc: # never raise into the auth path
|
||||
logger.warning("auth-failure audit recording failed: %s", exc)
|
||||
|
||||
|
||||
def _record_ws_auth_success(label: str, client_host: str | None) -> None:
|
||||
"""Best-effort: record a successful WebSocket session establishment."""
|
||||
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||
|
||||
rec = get_module_recorder()
|
||||
if rec is None:
|
||||
return
|
||||
rec.record(
|
||||
category=ActivityCategory.AUTH,
|
||||
action="auth.ws_connected",
|
||||
severity=ActivitySeverity.INFO,
|
||||
actor=label,
|
||||
message=f"WebSocket session established by '{label}'",
|
||||
metadata={"client": client_host or "unknown"},
|
||||
)
|
||||
|
||||
|
||||
# Security scheme for Bearer token
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
|
||||
|
||||
# Exceptions that legitimately fire when we try to send / close a WebSocket
|
||||
# that is already shutting down: the peer dropped, the connect-state moved
|
||||
# under us, the underlying socket is gone, the JSON encoder choked, etc.
|
||||
# Keeping this tuple narrow means a genuine programming error (AttributeError,
|
||||
# TypeError) bubbles up to the caller instead of silently disappearing.
|
||||
_WS_SEND_BENIGN_EXC: tuple[type[BaseException], ...] = (
|
||||
WebSocketDisconnect,
|
||||
RuntimeError,
|
||||
ConnectionError,
|
||||
OSError,
|
||||
)
|
||||
|
||||
|
||||
def is_auth_enabled() -> bool:
|
||||
@@ -26,18 +157,18 @@ def is_auth_enabled() -> bool:
|
||||
|
||||
|
||||
def _is_loopback(host: str | None) -> bool:
|
||||
"""Return True when *host* is a loopback address."""
|
||||
"""Return True when *host* is a loopback address.
|
||||
|
||||
Delegates to :func:`ledgrab.utils.net_classify.is_loopback` so this
|
||||
auth gate, the SSRF guard in ``safe_source``, and the LAN-default
|
||||
inference in ``url_scheme`` share one classification source.
|
||||
"""
|
||||
if not host:
|
||||
return False
|
||||
# Strip IPv6 brackets and zone IDs
|
||||
h = host.strip().lower()
|
||||
if h.startswith("[") and h.endswith("]"):
|
||||
h = h[1:-1]
|
||||
h = h.split("%", 1)[0]
|
||||
return h in _LOOPBACK_HOSTS
|
||||
return _classify_is_loopback(host)
|
||||
|
||||
|
||||
def verify_api_key(
|
||||
async def verify_api_key(
|
||||
request: Request,
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)],
|
||||
) -> str:
|
||||
@@ -51,6 +182,13 @@ def verify_api_key(
|
||||
LAN access requires an API key).
|
||||
- When API keys ARE configured, valid Bearer credentials are required.
|
||||
|
||||
This is an ``async`` dependency on purpose: token comparison is CPU-trivial,
|
||||
and an async dependency runs in the SAME task/context as the route handler,
|
||||
so ``current_actor.set(...)`` below is visible to ``ActivityRecorder`` when
|
||||
the handler later records an entity event. A sync dependency would run in a
|
||||
throwaway threadpool context and the actor mutation would be discarded,
|
||||
attributing every audited action to "system".
|
||||
|
||||
Args:
|
||||
request: incoming request (used to read client host)
|
||||
credentials: HTTP authorization credentials
|
||||
@@ -68,10 +206,13 @@ def verify_api_key(
|
||||
if not config.auth.api_keys:
|
||||
# No keys configured — allow loopback only.
|
||||
if _is_loopback(client_host):
|
||||
request.state.auth_label = "anonymous"
|
||||
current_actor.set("anonymous")
|
||||
return "anonymous"
|
||||
# Allow caller to authenticate explicitly even without configured keys?
|
||||
# No — there are no keys to compare against. Reject.
|
||||
logger.warning("Rejected LAN request from %s: no API key configured", client_host)
|
||||
_record_auth_failure("LAN access rejected: no API key configured", client_host)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=(
|
||||
@@ -84,24 +225,32 @@ def verify_api_key(
|
||||
# Check if credentials are provided
|
||||
if not credentials:
|
||||
logger.warning("Request missing Authorization header")
|
||||
_record_auth_failure("missing Bearer token", client_host)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing API key - authentication is required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Extract token
|
||||
# Extract token — NEVER log or record the token value itself.
|
||||
token = credentials.credentials
|
||||
|
||||
# Find matching key and return its label using constant-time comparison
|
||||
# Find matching key and return its label using constant-time comparison.
|
||||
# Compare UTF-8 byte encodings: secrets.compare_digest raises TypeError on
|
||||
# non-ASCII str (an attacker can put 0x80-0xFF in the Authorization header,
|
||||
# which Starlette latin-1-decodes to a non-ASCII str). Byte comparison is
|
||||
# well-defined for any input and preserves constant-time behavior, so a
|
||||
# bad/non-ASCII token cleanly falls through to the 401 below instead of 500.
|
||||
token_b = (token or "").encode("utf-8")
|
||||
authenticated_as = None
|
||||
for label, api_key in config.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
if secrets.compare_digest(token_b, api_key.encode("utf-8")):
|
||||
authenticated_as = label
|
||||
break
|
||||
|
||||
if not authenticated_as:
|
||||
logger.warning("Invalid API key attempt")
|
||||
_record_auth_failure("invalid Bearer token", client_host)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid API key",
|
||||
@@ -111,6 +260,12 @@ def verify_api_key(
|
||||
# Log successful authentication
|
||||
logger.debug(f"Authenticated as: {authenticated_as}")
|
||||
|
||||
# Stash the friendly label so the access-log middleware can attribute the
|
||||
# request to a client without re-running the token comparison.
|
||||
request.state.auth_label = authenticated_as
|
||||
# Set the actor ContextVar so ActivityRecorder can resolve it without
|
||||
# threading it through every call site.
|
||||
current_actor.set(authenticated_as)
|
||||
return authenticated_as
|
||||
|
||||
|
||||
@@ -119,6 +274,31 @@ def verify_api_key(
|
||||
AuthRequired = Annotated[str, Depends(verify_api_key)]
|
||||
|
||||
|
||||
async def verify_docs_access(
|
||||
request: Request,
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)],
|
||||
) -> str:
|
||||
"""Auth gate for the OpenAPI docs routes (/docs, /redoc, /openapi.json).
|
||||
|
||||
When ``auth.expose_docs`` is True, the docs pages load anonymously from any
|
||||
client (loopback and LAN) so they can be viewed in a browser without a
|
||||
Bearer token. Only the API *surface* is exposed this way — every other
|
||||
endpoint still goes through :func:`verify_api_key`.
|
||||
|
||||
When ``auth.expose_docs`` is False (default), this delegates to
|
||||
:func:`verify_api_key`, so docs require a token exactly like the rest of
|
||||
the API.
|
||||
"""
|
||||
if get_config().auth.expose_docs:
|
||||
request.state.auth_label = "anonymous-docs"
|
||||
return "anonymous-docs"
|
||||
return await verify_api_key(request, credentials)
|
||||
|
||||
|
||||
# Dependency for the OpenAPI docs routes — relaxed when auth.expose_docs is set
|
||||
DocsAccess = Annotated[str, Depends(verify_docs_access)]
|
||||
|
||||
|
||||
def require_authenticated(label: str) -> None:
|
||||
"""Reject the anonymous (loopback) auth label.
|
||||
|
||||
@@ -142,6 +322,23 @@ def require_authenticated(label: str) -> None:
|
||||
WS_AUTH_CLOSE_CODE = 4401
|
||||
|
||||
|
||||
WS_ORIGIN_CLOSE_CODE = 4403
|
||||
"""Close code sent when a WebSocket request fails the Origin allowlist."""
|
||||
|
||||
|
||||
def _is_origin_allowed(origin: str | None, allowed: list[str]) -> bool:
|
||||
"""Return True when *origin* matches one of the configured CORS origins.
|
||||
|
||||
Non-browser clients (Python scripts, curl) don't send Origin — those are
|
||||
allowed through; the Bearer-token check on the auth handshake is the
|
||||
primary defence in that case. Browsers always set Origin, so this only
|
||||
blocks cross-site WebSocket connection attempts (CSWSH).
|
||||
"""
|
||||
if not origin:
|
||||
return True
|
||||
return origin in set(allowed or [])
|
||||
|
||||
|
||||
async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0) -> str | None:
|
||||
"""Accept the WebSocket, then perform first-message auth handshake.
|
||||
|
||||
@@ -152,14 +349,50 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
|
||||
Returns the caller label on success, ``None`` on failure (connection
|
||||
already closed).
|
||||
"""
|
||||
# Reject cross-site WebSocket attempts before accepting — a browser-based
|
||||
# attacker page cannot forge the Origin header, so an Origin mismatch is
|
||||
# a strong signal even before the token check. Non-browser clients
|
||||
# legitimately omit Origin; those fall through to the auth handshake.
|
||||
config = get_config()
|
||||
client_host = websocket.client.host if websocket.client else None
|
||||
origin = websocket.headers.get("origin")
|
||||
if not _is_origin_allowed(origin, config.server.cors_origins):
|
||||
logger.warning(
|
||||
"Rejected WebSocket from origin %r (not in cors_origins)",
|
||||
origin,
|
||||
)
|
||||
# Sanitize first so urlparse does not choke on control chars / ANSI / NUL
|
||||
# embedded by an attacker in the Origin header (e.g. \n triggers IPv6 parse
|
||||
# error in Python's urlsplit on malformed netloc).
|
||||
_safe_origin_raw = sanitize_display(origin) if origin else ""
|
||||
try:
|
||||
_netloc = urlparse(_safe_origin_raw).netloc if _safe_origin_raw else ""
|
||||
except ValueError:
|
||||
# Malformed IPv6 addresses (e.g. "http://[::1" without closing "]")
|
||||
# cause urlparse to raise ValueError. Fall back to "unknown" — do NOT
|
||||
# fall back to the raw origin string, which could carry query params
|
||||
# or path components containing secrets.
|
||||
_netloc = ""
|
||||
_safe_origin = sanitize_display(_netloc or "unknown")
|
||||
_record_auth_failure(
|
||||
f"WebSocket origin rejected: {_safe_origin!r}",
|
||||
client_host,
|
||||
)
|
||||
try:
|
||||
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
pass
|
||||
return None
|
||||
|
||||
await websocket.accept()
|
||||
label = await verify_ws_auth(websocket, timeout=timeout)
|
||||
if label is None:
|
||||
try:
|
||||
await websocket.close(code=WS_AUTH_CLOSE_CODE)
|
||||
except Exception:
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
pass
|
||||
return None
|
||||
_record_ws_auth_success(label, client_host)
|
||||
return label
|
||||
|
||||
|
||||
@@ -167,12 +400,19 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
|
||||
|
||||
|
||||
def _match_api_key(token: str) -> str | None:
|
||||
"""Return the label matching *token* using constant-time comparison, or None."""
|
||||
"""Return the label matching *token* using constant-time comparison, or None.
|
||||
|
||||
Compares UTF-8 byte encodings so a non-ASCII token (a JSON string in the WS
|
||||
auth message trivially carries non-ASCII) cannot raise TypeError out of
|
||||
``secrets.compare_digest`` — it simply fails to match and yields a clean
|
||||
``auth_error`` instead of crashing the handler.
|
||||
"""
|
||||
config = get_config()
|
||||
if not token:
|
||||
return None
|
||||
token_b = token.encode("utf-8")
|
||||
for label, api_key in config.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
if secrets.compare_digest(token_b, api_key.encode("utf-8")):
|
||||
return label
|
||||
return None
|
||||
|
||||
@@ -221,20 +461,30 @@ async def verify_ws_auth(
|
||||
# Loopback anonymous: no auth message arrived, but none is required.
|
||||
try:
|
||||
await websocket.send_json({"type": "auth_ok"})
|
||||
except Exception:
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
return None
|
||||
return "anonymous"
|
||||
logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host)
|
||||
_record_auth_failure("WebSocket auth timeout", client_host)
|
||||
try:
|
||||
await websocket.send_json({"type": "auth_error", "reason": "auth timeout"})
|
||||
except Exception:
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
pass
|
||||
return None
|
||||
except WebSocketDisconnect:
|
||||
return None
|
||||
except Exception as exc:
|
||||
except (RuntimeError, ConnectionError, OSError) as exc:
|
||||
# The peer hung up mid-handshake or the underlying socket is gone.
|
||||
# Promote anything outside this set to a hard failure with a stack
|
||||
# trace so we can see real bugs (decode errors, type errors, …).
|
||||
logger.debug("WebSocket auth receive error: %s", exc)
|
||||
return None
|
||||
except Exception:
|
||||
# Unexpected — log the full traceback so we can see what we missed
|
||||
# without leaving the connection half-open. Re-raise nothing; the
|
||||
# caller will close on the None return.
|
||||
logger.exception("Unexpected error during WebSocket auth handshake")
|
||||
return None
|
||||
|
||||
# Parse the auth message.
|
||||
try:
|
||||
@@ -244,7 +494,7 @@ async def verify_ws_auth(
|
||||
await websocket.send_json(
|
||||
{"type": "auth_error", "reason": "invalid JSON in auth message"}
|
||||
)
|
||||
except Exception:
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -253,7 +503,7 @@ async def verify_ws_auth(
|
||||
await websocket.send_json(
|
||||
{"type": "auth_error", "reason": "first message must be {type:'auth'}"}
|
||||
)
|
||||
except Exception:
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -263,7 +513,7 @@ async def verify_ws_auth(
|
||||
await websocket.send_json(
|
||||
{"type": "auth_error", "reason": "token must be a string or null"}
|
||||
)
|
||||
except Exception:
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -273,6 +523,7 @@ async def verify_ws_auth(
|
||||
await websocket.send_json({"type": "auth_ok"})
|
||||
return "anonymous"
|
||||
logger.warning("Rejected LAN WebSocket from %s: no API key configured", client_host)
|
||||
_record_auth_failure("LAN WebSocket rejected: no API key configured", client_host)
|
||||
try:
|
||||
await websocket.send_json(
|
||||
{
|
||||
@@ -280,23 +531,24 @@ async def verify_ws_auth(
|
||||
"reason": "LAN access requires an API key",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
pass
|
||||
return None
|
||||
|
||||
# Keys configured: require a matching token.
|
||||
# Keys configured: require a matching token. NEVER log the token value.
|
||||
label = _match_api_key(token or "")
|
||||
if not label:
|
||||
logger.warning("Invalid WebSocket auth attempt from %s", client_host)
|
||||
_record_auth_failure("invalid WebSocket token", client_host)
|
||||
try:
|
||||
await websocket.send_json({"type": "auth_error", "reason": "invalid token"})
|
||||
except Exception:
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
pass
|
||||
return None
|
||||
|
||||
try:
|
||||
await websocket.send_json({"type": "auth_ok"})
|
||||
except Exception:
|
||||
except _WS_SEND_BENIGN_EXC:
|
||||
return None
|
||||
logger.debug("WebSocket authenticated as: %s", label)
|
||||
return label
|
||||
|
||||
@@ -19,6 +19,7 @@ from ledgrab.storage.audio_template_store import AudioTemplateStore
|
||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||
from ledgrab.storage.automation_store import AutomationStore
|
||||
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
||||
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
|
||||
from ledgrab.storage.sync_clock_store import SyncClockStore
|
||||
from ledgrab.storage.color_strip_processing_template_store import (
|
||||
ColorStripProcessingTemplateStore,
|
||||
@@ -27,6 +28,7 @@ from ledgrab.storage.gradient_store import GradientStore
|
||||
from ledgrab.storage.weather_source_store import WeatherSourceStore
|
||||
from ledgrab.storage.asset_store import AssetStore
|
||||
from ledgrab.core.automations.automation_engine import AutomationEngine
|
||||
from ledgrab.core.scenes.playlist_engine import PlaylistEngine
|
||||
from ledgrab.core.weather.weather_manager import WeatherManager
|
||||
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
||||
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
||||
@@ -35,10 +37,17 @@ from ledgrab.storage.home_assistant_store import HomeAssistantStore
|
||||
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
|
||||
from ledgrab.storage.game_integration_store import GameIntegrationStore
|
||||
from ledgrab.core.game_integration.event_bus import GameEventBus
|
||||
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
|
||||
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
|
||||
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
|
||||
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
|
||||
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
|
||||
from ledgrab.storage.pattern_template_store import PatternTemplateStore
|
||||
from ledgrab.core.activity_log.recorder import ActivityRecorder, get_module_recorder
|
||||
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
|
||||
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||
from ledgrab.storage.activity_log_repository import ActivityLogRepository
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -109,6 +118,14 @@ def get_automation_engine() -> AutomationEngine:
|
||||
return _get("automation_engine", "Automation engine")
|
||||
|
||||
|
||||
def get_scene_playlist_store() -> ScenePlaylistStore:
|
||||
return _get("scene_playlist_store", "Scene playlist store")
|
||||
|
||||
|
||||
def get_playlist_engine() -> PlaylistEngine:
|
||||
return _get("playlist_engine", "Playlist engine")
|
||||
|
||||
|
||||
def get_auto_backup_engine() -> AutoBackupEngine:
|
||||
return _get("auto_backup_engine", "Auto-backup engine")
|
||||
|
||||
@@ -157,6 +174,15 @@ def get_game_event_bus() -> GameEventBus:
|
||||
return _get("game_event_bus", "Game event bus")
|
||||
|
||||
|
||||
def get_lol_poll_manager() -> LoLPollManager | None:
|
||||
"""LoL poll manager, or None if not wired (e.g. minimal test harnesses).
|
||||
|
||||
Polling is a best-effort background feature, so callers guard on None
|
||||
rather than 500-ing a CRUD request when the manager is absent.
|
||||
"""
|
||||
return _deps.get("lol_poll_manager")
|
||||
|
||||
|
||||
def get_mqtt_store() -> MQTTSourceStore:
|
||||
return _get("mqtt_store", "MQTT source store")
|
||||
|
||||
@@ -165,6 +191,10 @@ def get_mqtt_manager() -> MQTTManager:
|
||||
return _get("mqtt_manager", "MQTT manager")
|
||||
|
||||
|
||||
def get_http_endpoint_store() -> HTTPEndpointStore:
|
||||
return _get("http_endpoint_store", "HTTP endpoint store")
|
||||
|
||||
|
||||
def get_audio_processing_template_store() -> AudioProcessingTemplateStore:
|
||||
return _get("audio_processing_template_store", "Audio processing template store")
|
||||
|
||||
@@ -181,16 +211,87 @@ def get_update_service() -> UpdateService:
|
||||
return _get("update_service", "Update service")
|
||||
|
||||
|
||||
def get_activity_recorder() -> ActivityRecorder:
|
||||
return _get("activity_recorder", "Activity recorder")
|
||||
|
||||
|
||||
def get_activity_log_repo() -> ActivityLogRepository:
|
||||
return _get("activity_log_repo", "Activity log repository")
|
||||
|
||||
|
||||
def get_activity_log_retention_engine() -> ActivityLogRetentionEngine:
|
||||
return _get("activity_log_retention_engine", "Activity log retention engine")
|
||||
|
||||
|
||||
# ── Event helper ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
|
||||
"""Fire an entity_changed event via the ProcessorManager event bus.
|
||||
# entity_type → (_deps key, store method name) for human-name resolution.
|
||||
# Module-level constant: built once at import rather than per audited mutation
|
||||
# (``_resolve_entity_name`` is the create/update audit choke point).
|
||||
_STORE_LOOKUP: dict[str, tuple[str, str]] = {
|
||||
"output_target": ("output_target_store", "get_target"),
|
||||
"device": ("device_store", "get_device"),
|
||||
"picture_source": ("picture_source_store", "get_source"),
|
||||
"audio_source": ("audio_source_store", "get_source"),
|
||||
"color_strip_source": ("color_strip_store", "get_source"),
|
||||
"template": ("template_store", "get_template"),
|
||||
"capture_template": ("template_store", "get_template"),
|
||||
"pp_template": ("pp_template_store", "get_template"),
|
||||
"automation": ("automation_store", "get_automation"),
|
||||
"scene_preset": ("scene_preset_store", "get_preset"),
|
||||
"scene_playlist": ("scene_playlist_store", "get_playlist"),
|
||||
"sync_clock": ("sync_clock_store", "get_clock"),
|
||||
"gradient": ("gradient_store", "get_gradient"),
|
||||
"audio_template": ("audio_template_store", "get_template"),
|
||||
"value_source": ("value_source_store", "get_source"),
|
||||
"cspt": ("cspt_store", "get_template"),
|
||||
"audio_processing_template": ("audio_processing_template_store", "get_template"),
|
||||
"pattern_template": ("pattern_template_store", "get_template"),
|
||||
"home_assistant_source": ("ha_store", "get_source"),
|
||||
"mqtt_source": ("mqtt_store", "get_source"),
|
||||
"http_endpoint": ("http_endpoint_store", "get_endpoint"),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_entity_name(entity_type: str, entity_id: str) -> str | None:
|
||||
"""Best-effort: look up a human name for *entity_id* from the matching store.
|
||||
|
||||
Returns ``None`` when the store is missing, the entity is gone, or any
|
||||
exception occurs (e.g. during delete the entity may have just been removed).
|
||||
"""
|
||||
entry = _STORE_LOOKUP.get(entity_type)
|
||||
if entry is None:
|
||||
return None
|
||||
store_key, method_name = entry
|
||||
store = _deps.get(store_key)
|
||||
if store is None:
|
||||
return None
|
||||
try:
|
||||
obj = getattr(store, method_name)(entity_id)
|
||||
if obj is not None:
|
||||
return getattr(obj, "name", None)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def fire_entity_event(
|
||||
entity_type: str,
|
||||
action: str,
|
||||
entity_id: str,
|
||||
entity_name: str | None = None,
|
||||
) -> None:
|
||||
"""Fire an entity_changed event via the ProcessorManager event bus and
|
||||
record an audit entry.
|
||||
|
||||
Args:
|
||||
entity_type: e.g. "device", "output_target", "color_strip_source"
|
||||
action: "created", "updated", or "deleted"
|
||||
entity_id: The entity's unique ID
|
||||
entity_name: Human-readable name. For deletes: **must** be passed
|
||||
explicitly (entity is already gone when we get here).
|
||||
For create/update: resolved from the store when not supplied.
|
||||
"""
|
||||
pm = _deps.get("processor_manager")
|
||||
if pm is not None:
|
||||
@@ -203,6 +304,38 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
|
||||
}
|
||||
)
|
||||
|
||||
# ── Audit record (best-effort) ──────────────────────────────────────────
|
||||
rec = get_module_recorder()
|
||||
if rec is None:
|
||||
return
|
||||
|
||||
# Resolve name when not explicitly provided (create / update paths).
|
||||
# For deleted: entity already gone — rely on the explicitly passed name.
|
||||
resolved_name = entity_name
|
||||
if resolved_name is None and action != "deleted":
|
||||
resolved_name = _resolve_entity_name(entity_type, entity_id)
|
||||
|
||||
# Build a concise human message.
|
||||
# Sanitize the display name before interpolating into the free-text message
|
||||
# (user-authored names hit the CSV/export trust surface).
|
||||
safe_display_name = sanitize_display(resolved_name) if resolved_name else None
|
||||
display_name = f"'{safe_display_name}'" if safe_display_name else entity_id
|
||||
action_word = {"created": "created", "updated": "updated", "deleted": "deleted"}.get(
|
||||
action, action
|
||||
)
|
||||
entity_label = entity_type.replace("_", " ")
|
||||
message = f"{entity_label.capitalize()} {display_name} {action_word}"
|
||||
|
||||
rec.record(
|
||||
category=ActivityCategory.ENTITY,
|
||||
action=f"entity.{action}",
|
||||
severity=ActivitySeverity.INFO,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
entity_name=sanitize_display(resolved_name) if resolved_name else None,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
# ── Initialization ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -221,7 +354,9 @@ def init_dependencies(
|
||||
value_source_store: ValueSourceStore | None = None,
|
||||
automation_store: AutomationStore | None = None,
|
||||
scene_preset_store: ScenePresetStore | None = None,
|
||||
scene_playlist_store: ScenePlaylistStore | None = None,
|
||||
automation_engine: AutomationEngine | None = None,
|
||||
playlist_engine: PlaylistEngine | None = None,
|
||||
auto_backup_engine: AutoBackupEngine | None = None,
|
||||
sync_clock_store: SyncClockStore | None = None,
|
||||
sync_clock_manager: SyncClockManager | None = None,
|
||||
@@ -235,10 +370,15 @@ def init_dependencies(
|
||||
ha_manager: HomeAssistantManager | None = None,
|
||||
game_integration_store: GameIntegrationStore | None = None,
|
||||
game_event_bus: GameEventBus | None = None,
|
||||
lol_poll_manager: LoLPollManager | None = None,
|
||||
mqtt_store: MQTTSourceStore | None = None,
|
||||
mqtt_manager: MQTTManager | None = None,
|
||||
http_endpoint_store: HTTPEndpointStore | None = None,
|
||||
audio_processing_template_store: AudioProcessingTemplateStore | None = None,
|
||||
pattern_template_store: PatternTemplateStore | None = None,
|
||||
activity_recorder: ActivityRecorder | None = None,
|
||||
activity_log_repo: ActivityLogRepository | None = None,
|
||||
activity_log_retention_engine: ActivityLogRetentionEngine | None = None,
|
||||
):
|
||||
"""Initialize global dependencies."""
|
||||
_deps.update(
|
||||
@@ -256,7 +396,9 @@ def init_dependencies(
|
||||
"value_source_store": value_source_store,
|
||||
"automation_store": automation_store,
|
||||
"scene_preset_store": scene_preset_store,
|
||||
"scene_playlist_store": scene_playlist_store,
|
||||
"automation_engine": automation_engine,
|
||||
"playlist_engine": playlist_engine,
|
||||
"auto_backup_engine": auto_backup_engine,
|
||||
"sync_clock_store": sync_clock_store,
|
||||
"sync_clock_manager": sync_clock_manager,
|
||||
@@ -270,9 +412,14 @@ def init_dependencies(
|
||||
"ha_manager": ha_manager,
|
||||
"game_integration_store": game_integration_store,
|
||||
"game_event_bus": game_event_bus,
|
||||
"lol_poll_manager": lol_poll_manager,
|
||||
"mqtt_store": mqtt_store,
|
||||
"mqtt_manager": mqtt_manager,
|
||||
"http_endpoint_store": http_endpoint_store,
|
||||
"audio_processing_template_store": audio_processing_template_store,
|
||||
"pattern_template_store": pattern_template_store,
|
||||
"activity_recorder": activity_recorder,
|
||||
"activity_log_repo": activity_log_repo,
|
||||
"activity_log_retention_engine": activity_log_retention_engine,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
"""Authoritative wiring-graph schema and topology engine.
|
||||
|
||||
This module is the single source of truth for **which reference fields connect
|
||||
which entity kinds**. The frontend graph editor historically hard-coded the same
|
||||
information in two places (``graph-connections.ts`` ``CONNECTION_MAP`` and
|
||||
``graph-layout.ts`` ``buildGraph``); the ``GET /api/v1/graph/schema`` endpoint
|
||||
now serves this registry so the client can render ports and edges generically
|
||||
and the two never drift.
|
||||
|
||||
This registry is a *superset* of the current frontend ``buildGraph``: it also
|
||||
declares real references that ``buildGraph`` does not yet draw (e.g.
|
||||
``value_source.value_source_id`` chaining and ``value_source.color_strip_source_id``).
|
||||
The backend is authoritative; the client is expected to converge on it.
|
||||
|
||||
Everything in this module is pure (operates on plain dicts), so the topology
|
||||
build, dependency lookup, cycle and dangling-reference detection are all unit
|
||||
testable without booting the app or any store.
|
||||
|
||||
Field-path grammar (the ``field`` of a :class:`ConnectionField`):
|
||||
|
||||
* ``"device_id"`` — a top-level string id.
|
||||
* ``"brightness.source_id"`` — a nested object; ``brightness`` may be a
|
||||
plain number (unbound :class:`BindableFloat`) or ``{"value", "source_id"}``.
|
||||
* ``"settings.pattern_template_id"`` — arbitrarily deep object access.
|
||||
* ``"layers[].source_id"`` — ``layers`` is a list; read ``source_id``
|
||||
from every element.
|
||||
* ``"calibration.lines[].picture_source_id"`` — object → list → field.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import asdict, dataclass, is_dataclass
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConnectionField:
|
||||
"""One connectable reference: ``target_kind.field`` points at ``source_kind``."""
|
||||
|
||||
target_kind: str
|
||||
"""Entity kind that *holds* the reference (the consumer / referrer)."""
|
||||
field: str
|
||||
"""Dot-path to the reference value (see module docstring grammar)."""
|
||||
source_kind: str
|
||||
"""Entity kind being referenced (the producer / source)."""
|
||||
edge_type: str
|
||||
"""Edge category, used by the client for colour and port grouping."""
|
||||
bindable: bool = False
|
||||
"""True when the slot is a :class:`BindableFloat`/``BindableColor`` value binding."""
|
||||
nested: bool = False
|
||||
"""True when the field lives inside a nested object/list (dotted path)."""
|
||||
|
||||
@property
|
||||
def is_list(self) -> bool:
|
||||
"""True when any path segment iterates a list (``foo[]``)."""
|
||||
return "[]" in self.field
|
||||
|
||||
|
||||
# ── Entity kinds & their human "type" attribute ────────────────────────────
|
||||
# Mirrors the frontend buildGraph(): kind → the serialized field that carries
|
||||
# the entity's subtype (used only for the node label / icon).
|
||||
NODE_TYPE_FIELD: dict[str, str] = {
|
||||
"device": "device_type",
|
||||
"capture_template": "engine_type",
|
||||
"pp_template": "",
|
||||
"audio_template": "engine_type",
|
||||
"pattern_template": "",
|
||||
"picture_source": "stream_type",
|
||||
"audio_source": "source_type",
|
||||
"value_source": "source_type",
|
||||
"color_strip_source": "source_type",
|
||||
"sync_clock": "",
|
||||
"output_target": "target_type",
|
||||
"scene_preset": "",
|
||||
"automation": "",
|
||||
"cspt": "",
|
||||
}
|
||||
|
||||
ENTITY_KINDS: tuple[str, ...] = tuple(NODE_TYPE_FIELD.keys())
|
||||
|
||||
|
||||
# ── The registry ───────────────────────────────────────────────────────────
|
||||
# NOTE: ``gradient`` and ``ha_source`` reference fields are intentionally
|
||||
# omitted — they are not first-class graph node kinds, so wiring them would
|
||||
# only ever produce dangling-reference noise.
|
||||
CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
|
||||
# ── Picture sources ──
|
||||
ConnectionField("picture_source", "capture_template_id", "capture_template", "template"),
|
||||
ConnectionField("picture_source", "source_stream_id", "picture_source", "picture"),
|
||||
ConnectionField("picture_source", "postprocessing_template_id", "pp_template", "template"),
|
||||
# ── Audio sources ──
|
||||
ConnectionField("audio_source", "audio_template_id", "audio_template", "audio"),
|
||||
ConnectionField("audio_source", "audio_source_id", "audio_source", "audio"),
|
||||
# ── Value sources ──
|
||||
ConnectionField("value_source", "audio_source_id", "audio_source", "audio"),
|
||||
ConnectionField("value_source", "picture_source_id", "picture_source", "picture"),
|
||||
ConnectionField("value_source", "value_source_id", "value_source", "value"),
|
||||
ConnectionField("value_source", "color_strip_source_id", "color_strip_source", "colorstrip"),
|
||||
# AnimatedColorValueSource references a sync clock for shared timing.
|
||||
ConnectionField("value_source", "clock_id", "sync_clock", "clock"),
|
||||
# ── Color strip sources (top-level) ──
|
||||
ConnectionField("color_strip_source", "picture_source_id", "picture_source", "picture"),
|
||||
ConnectionField("color_strip_source", "audio_source_id", "audio_source", "audio"),
|
||||
ConnectionField("color_strip_source", "clock_id", "sync_clock", "clock"),
|
||||
ConnectionField("color_strip_source", "input_source_id", "color_strip_source", "colorstrip"),
|
||||
ConnectionField("color_strip_source", "processing_template_id", "cspt", "template"),
|
||||
# ── Color strip sources (BindableFloat value bindings) ──
|
||||
*(
|
||||
ConnectionField(
|
||||
"color_strip_source",
|
||||
f"{prop}.source_id",
|
||||
"value_source",
|
||||
"value",
|
||||
bindable=True,
|
||||
nested=True,
|
||||
)
|
||||
for prop in (
|
||||
"smoothing",
|
||||
"sensitivity",
|
||||
"intensity",
|
||||
"scale",
|
||||
"speed",
|
||||
"wind_strength",
|
||||
"temperature_influence",
|
||||
"sound_volume",
|
||||
"timeout",
|
||||
"brightness",
|
||||
)
|
||||
),
|
||||
# ── Color strip sources (BindableColor value bindings) ──
|
||||
# NOTE: `bindable` here is *structural* (these are BindableColor fields). They
|
||||
# are NOT usefully wireable from the graph: a ValueStream yields a scalar
|
||||
# (`get_value() -> float`) and every colour consumer reads the static RGB via
|
||||
# `bcolor()` (source_id ignored at runtime). The graph editor keeps them
|
||||
# read-only; do not enable them without a colour-producing value source.
|
||||
*(
|
||||
ConnectionField(
|
||||
"color_strip_source",
|
||||
f"{prop}.source_id",
|
||||
"value_source",
|
||||
"value",
|
||||
bindable=True,
|
||||
nested=True,
|
||||
)
|
||||
for prop in ("color", "color_peak", "fallback_color", "default_color")
|
||||
),
|
||||
# ── Color strip sources (composite layers / mapped zones / calibration) ──
|
||||
ConnectionField(
|
||||
"color_strip_source", "layers[].source_id", "color_strip_source", "colorstrip", nested=True
|
||||
),
|
||||
ConnectionField(
|
||||
"color_strip_source",
|
||||
"layers[].brightness_source_id",
|
||||
"value_source",
|
||||
"value",
|
||||
bindable=True,
|
||||
nested=True,
|
||||
),
|
||||
ConnectionField(
|
||||
"color_strip_source", "layers[].processing_template_id", "cspt", "template", nested=True
|
||||
),
|
||||
ConnectionField(
|
||||
"color_strip_source", "zones[].source_id", "color_strip_source", "colorstrip", nested=True
|
||||
),
|
||||
ConnectionField(
|
||||
"color_strip_source",
|
||||
"calibration.lines[].picture_source_id",
|
||||
"picture_source",
|
||||
"picture",
|
||||
nested=True,
|
||||
),
|
||||
# ── Output targets ──
|
||||
ConnectionField("output_target", "device_id", "device", "device"),
|
||||
ConnectionField("output_target", "color_strip_source_id", "color_strip_source", "colorstrip"),
|
||||
ConnectionField(
|
||||
"output_target", "brightness.source_id", "value_source", "value", bindable=True, nested=True
|
||||
),
|
||||
ConnectionField(
|
||||
"output_target", "transition.source_id", "value_source", "value", bindable=True, nested=True
|
||||
),
|
||||
ConnectionField(
|
||||
"output_target", "settings.pattern_template_id", "pattern_template", "template", nested=True
|
||||
),
|
||||
ConnectionField(
|
||||
"output_target",
|
||||
"settings.brightness.source_id",
|
||||
"value_source",
|
||||
"value",
|
||||
bindable=True,
|
||||
nested=True,
|
||||
),
|
||||
# ── Scene presets ──
|
||||
ConnectionField("scene_preset", "targets[].target_id", "output_target", "scene", nested=True),
|
||||
# ── Automations ──
|
||||
ConnectionField("automation", "scene_preset_id", "scene_preset", "scene"),
|
||||
ConnectionField("automation", "deactivation_scene_preset_id", "scene_preset", "scene"),
|
||||
# ── Devices ──
|
||||
ConnectionField("device", "default_css_processing_template_id", "cspt", "template"),
|
||||
)
|
||||
|
||||
|
||||
def schema_for_kind(kind: str) -> list[ConnectionField]:
|
||||
"""Every connectable field whose *referrer* is ``kind``."""
|
||||
return [c for c in CONNECTION_SCHEMA if c.target_kind == kind]
|
||||
|
||||
|
||||
# BindableColor slots are structurally bindable but NOT graph-editable: a
|
||||
# ValueStream yields a scalar (``get_value() -> float``) and colour consumers
|
||||
# read the static RGB via ``bcolor()`` (source_id ignored at runtime), so a
|
||||
# value source cannot drive a colour.
|
||||
_COLOR_BINDABLE_FIELDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"color.source_id",
|
||||
"color_peak.source_id",
|
||||
"fallback_color.source_id",
|
||||
"default_color.source_id",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_editable(cf: ConnectionField) -> bool:
|
||||
"""Whether a field can be wired from the graph.
|
||||
|
||||
Editable = a top-level reference, or a single-level ``BindableFloat`` slot.
|
||||
List slots (need an element index), double-nested fields, and the dead
|
||||
colour bindings stay read-only.
|
||||
"""
|
||||
if cf.is_list:
|
||||
return False
|
||||
if not cf.nested:
|
||||
return True
|
||||
return cf.bindable and cf.field.count(".") == 1 and cf.field not in _COLOR_BINDABLE_FIELDS
|
||||
|
||||
|
||||
def schema_as_dicts() -> list[dict[str, Any]]:
|
||||
"""Serialize the registry for the ``/graph/schema`` endpoint."""
|
||||
return [
|
||||
{
|
||||
"target_kind": c.target_kind,
|
||||
"field": c.field,
|
||||
"source_kind": c.source_kind,
|
||||
"edge_type": c.edge_type,
|
||||
"bindable": c.bindable,
|
||||
"nested": c.nested,
|
||||
"is_list": c.is_list,
|
||||
"editable": is_editable(c),
|
||||
}
|
||||
for c in CONNECTION_SCHEMA
|
||||
]
|
||||
|
||||
|
||||
# ── Reference extraction ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def extract_refs(entity: dict[str, Any], field_path: str) -> list[str]:
|
||||
"""Resolve a (possibly nested/list) ``field_path`` to its referenced ids.
|
||||
|
||||
Returns only non-empty string ids. Tolerant of missing keys, ``None``
|
||||
values and unbound bindables (a plain number where an object was expected).
|
||||
"""
|
||||
current: list[Any] = [entity]
|
||||
for segment in field_path.split("."):
|
||||
is_list = segment.endswith("[]")
|
||||
key = segment[:-2] if is_list else segment
|
||||
nxt: list[Any] = []
|
||||
for obj in current:
|
||||
if not isinstance(obj, dict):
|
||||
continue
|
||||
val = obj.get(key)
|
||||
if is_list:
|
||||
if isinstance(val, list):
|
||||
nxt.extend(val)
|
||||
elif val is not None:
|
||||
nxt.append(val)
|
||||
current = nxt
|
||||
return [v for v in current if isinstance(v, str) and v]
|
||||
|
||||
|
||||
def remap_refs(entity: dict[str, Any], field_path: str, id_map: dict[str, str]) -> int:
|
||||
"""Rewrite referenced ids under ``field_path`` *in place*, using ``id_map``.
|
||||
|
||||
The write-twin of :func:`extract_refs`: it walks the same dot/list/bindable
|
||||
grammar and replaces any leaf id present in ``id_map`` with its mapped value.
|
||||
Ids absent from ``id_map`` (references to entities outside the remap set) are
|
||||
left untouched, so a clone keeps sharing its un-cloned dependencies. Unbound
|
||||
bindables (a plain number where an object was expected) and missing keys are
|
||||
tolerated. Returns the number of ids rewritten.
|
||||
"""
|
||||
segments = field_path.split(".")
|
||||
# Descend to the container(s) that hold the final key.
|
||||
parents: list[Any] = [entity]
|
||||
for segment in segments[:-1]:
|
||||
is_list = segment.endswith("[]")
|
||||
key = segment[:-2] if is_list else segment
|
||||
nxt: list[Any] = []
|
||||
for obj in parents:
|
||||
if not isinstance(obj, dict):
|
||||
continue
|
||||
val = obj.get(key)
|
||||
if is_list:
|
||||
if isinstance(val, list):
|
||||
nxt.extend(val)
|
||||
elif isinstance(val, dict):
|
||||
nxt.append(val)
|
||||
parents = nxt
|
||||
|
||||
last = segments[-1]
|
||||
last_is_list = last.endswith("[]")
|
||||
key = last[:-2] if last_is_list else last
|
||||
count = 0
|
||||
for obj in parents:
|
||||
if not isinstance(obj, dict):
|
||||
continue
|
||||
val = obj.get(key)
|
||||
if last_is_list:
|
||||
if isinstance(val, list):
|
||||
for i, item in enumerate(val):
|
||||
if isinstance(item, str) and item in id_map:
|
||||
val[i] = id_map[item]
|
||||
count += 1
|
||||
elif isinstance(val, str) and val in id_map:
|
||||
obj[key] = id_map[val]
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def serialize_entity(model: Any) -> dict[str, Any]:
|
||||
"""Best-effort serialize a storage model to a plain dict for graph use.
|
||||
|
||||
Prefers ``dataclasses.asdict`` (pure structural, recurses bindables/lists,
|
||||
invokes no managers), falling back to ``to_dict()`` then ``{}``.
|
||||
"""
|
||||
if is_dataclass(model) and not isinstance(model, type):
|
||||
try:
|
||||
return asdict(model)
|
||||
except Exception as exc: # noqa: BLE001 — defensive: never let one model break the graph
|
||||
logger.debug("graph: asdict failed for %r: %s", type(model).__name__, exc)
|
||||
to_dict = getattr(model, "to_dict", None)
|
||||
if callable(to_dict):
|
||||
try:
|
||||
result = to_dict()
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("graph: to_dict failed for %r: %s", type(model).__name__, exc)
|
||||
logger.warning(
|
||||
"graph: could not serialize model %r; excluding from graph", type(model).__name__
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
def graph_field_roots(kind: str) -> set[str]:
|
||||
"""Top-level keys the graph needs for ``kind``: ``id``/``name``, the subtype
|
||||
field, and the root segment of every reference path for that kind."""
|
||||
roots: set[str] = {"id", "name"}
|
||||
type_field = NODE_TYPE_FIELD.get(kind, "")
|
||||
if type_field:
|
||||
roots.add(type_field)
|
||||
for cf in CONNECTION_SCHEMA:
|
||||
if cf.target_kind == kind:
|
||||
roots.add(cf.field.split(".", 1)[0].removesuffix("[]"))
|
||||
return roots
|
||||
|
||||
|
||||
def serialize_entity_for_graph(kind: str, model: Any) -> dict[str, Any]:
|
||||
"""Serialize a model and project it to ONLY the keys the graph needs.
|
||||
|
||||
This projection is a **security boundary**: a full ``asdict``/``to_dict``
|
||||
can carry secrets (webhook tokens, device/HA/MQTT credentials), so every
|
||||
field except ``id``/``name``, the subtype field and reference-path roots is
|
||||
dropped before the data reaches the graph API.
|
||||
"""
|
||||
full = serialize_entity(model)
|
||||
roots = graph_field_roots(kind)
|
||||
return {k: v for k, v in full.items() if k in roots}
|
||||
|
||||
|
||||
# ── Topology / validation ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _node_from(kind: str, entity: dict[str, Any]) -> dict[str, Any] | None:
|
||||
eid = entity.get("id")
|
||||
if not isinstance(eid, str) or not eid:
|
||||
return None
|
||||
type_field = NODE_TYPE_FIELD.get(kind, "")
|
||||
subtype = entity.get(type_field, "") if type_field else ""
|
||||
return {
|
||||
"id": eid,
|
||||
"kind": kind,
|
||||
"name": entity.get("name") or eid,
|
||||
"type": subtype if isinstance(subtype, str) else "",
|
||||
}
|
||||
|
||||
|
||||
def build_topology(entities_by_kind: dict[str, list[dict[str, Any]]]) -> dict[str, Any]:
|
||||
"""Build the full wiring graph + a validation report.
|
||||
|
||||
Args:
|
||||
entities_by_kind: ``{kind: [serialized_entity_dict, ...]}``.
|
||||
|
||||
Returns a dict with ``nodes``, ``edges`` and ``issues`` (``orphans``,
|
||||
``broken_refs``, ``cycles``).
|
||||
"""
|
||||
nodes: list[dict[str, Any]] = []
|
||||
node_ids: set[str] = set()
|
||||
for kind in ENTITY_KINDS:
|
||||
for entity in entities_by_kind.get(kind, []):
|
||||
node = _node_from(kind, entity)
|
||||
if node and node["id"] not in node_ids:
|
||||
node_ids.add(node["id"])
|
||||
nodes.append(node)
|
||||
|
||||
edges: list[dict[str, Any]] = []
|
||||
broken_refs: list[dict[str, str]] = []
|
||||
for cf in CONNECTION_SCHEMA:
|
||||
for entity in entities_by_kind.get(cf.target_kind, []):
|
||||
referrer = entity.get("id")
|
||||
if not isinstance(referrer, str) or not referrer:
|
||||
continue
|
||||
for ref in extract_refs(entity, cf.field):
|
||||
if ref not in node_ids:
|
||||
broken_refs.append({"ref": ref, "by": referrer, "field": cf.field})
|
||||
continue
|
||||
edges.append(
|
||||
{
|
||||
"from": ref,
|
||||
"to": referrer,
|
||||
"field": cf.field,
|
||||
"edge_type": cf.edge_type,
|
||||
"nested": cf.nested,
|
||||
}
|
||||
)
|
||||
|
||||
connected: set[str] = set()
|
||||
for e in edges:
|
||||
connected.add(e["from"])
|
||||
connected.add(e["to"])
|
||||
orphans = sorted(nid for nid in node_ids if nid not in connected)
|
||||
cycles = sorted(detect_cycles(edges))
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"issues": {
|
||||
"orphans": orphans,
|
||||
"broken_refs": broken_refs,
|
||||
"cycles": cycles,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def find_dependents(
|
||||
entities_by_kind: dict[str, list[dict[str, Any]]], kind: str, entity_id: str
|
||||
) -> list[dict[str, str]]:
|
||||
"""Return every entity that references ``(kind, entity_id)``.
|
||||
|
||||
``kind`` is the kind of the *referenced* entity; matching schema entries are
|
||||
those whose ``source_kind == kind``.
|
||||
"""
|
||||
name_by_id: dict[str, str] = {}
|
||||
for k in ENTITY_KINDS:
|
||||
for entity in entities_by_kind.get(k, []):
|
||||
eid = entity.get("id")
|
||||
if isinstance(eid, str):
|
||||
name_by_id[eid] = entity.get("name") or eid
|
||||
|
||||
dependents: list[dict[str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for cf in CONNECTION_SCHEMA:
|
||||
if cf.source_kind != kind:
|
||||
continue
|
||||
for entity in entities_by_kind.get(cf.target_kind, []):
|
||||
referrer = entity.get("id")
|
||||
if not isinstance(referrer, str):
|
||||
continue
|
||||
if entity_id in extract_refs(entity, cf.field):
|
||||
key = (referrer, cf.field)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
dependents.append(
|
||||
{
|
||||
"id": referrer,
|
||||
"kind": cf.target_kind,
|
||||
"name": name_by_id.get(referrer, referrer),
|
||||
"field": cf.field,
|
||||
}
|
||||
)
|
||||
return dependents
|
||||
|
||||
|
||||
def detect_cycles(edges: list[dict[str, Any]]) -> set[str]:
|
||||
"""Return every node id that participates in a directed cycle (from→to)."""
|
||||
adj: dict[str, list[str]] = {}
|
||||
for e in edges:
|
||||
adj.setdefault(e["from"], []).append(e["to"])
|
||||
|
||||
WHITE, GRAY, BLACK = 0, 1, 2
|
||||
color: dict[str, int] = {}
|
||||
in_cycle: set[str] = set()
|
||||
|
||||
for start in list(adj.keys()):
|
||||
if color.get(start, WHITE) != WHITE:
|
||||
continue
|
||||
stack: list[tuple[str, int]] = [(start, 0)]
|
||||
path: list[str] = [start]
|
||||
color[start] = GRAY
|
||||
while stack:
|
||||
node, idx = stack[-1]
|
||||
neighbors = adj.get(node, [])
|
||||
if idx < len(neighbors):
|
||||
stack[-1] = (node, idx + 1)
|
||||
nxt = neighbors[idx]
|
||||
c = color.get(nxt, WHITE)
|
||||
if c == GRAY:
|
||||
if nxt in path:
|
||||
i = path.index(nxt)
|
||||
in_cycle.update(path[i:])
|
||||
elif c == WHITE:
|
||||
color[nxt] = GRAY
|
||||
path.append(nxt)
|
||||
stack.append((nxt, 0))
|
||||
else:
|
||||
color[node] = BLACK
|
||||
if path and path[-1] == node:
|
||||
path.pop()
|
||||
stack.pop()
|
||||
return in_cycle
|
||||
|
||||
|
||||
def _reachable(edges: list[dict[str, Any]], start: str, goal: str) -> bool:
|
||||
"""True if ``goal`` is reachable from ``start`` following from→to edges."""
|
||||
if start == goal:
|
||||
return True
|
||||
adj: dict[str, list[str]] = {}
|
||||
for e in edges:
|
||||
adj.setdefault(e["from"], []).append(e["to"])
|
||||
seen = {start}
|
||||
queue = [start]
|
||||
while queue:
|
||||
cur = queue.pop()
|
||||
for nxt in adj.get(cur, []):
|
||||
if nxt == goal:
|
||||
return True
|
||||
if nxt not in seen:
|
||||
seen.add(nxt)
|
||||
queue.append(nxt)
|
||||
return False
|
||||
|
||||
|
||||
def would_create_cycle(edges: list[dict[str, Any]], source_id: str, target_id: str) -> bool:
|
||||
"""Would wiring ``source_id`` into ``target_id`` (edge source→target) loop?
|
||||
|
||||
A cycle forms if ``source_id`` is already reachable from ``target_id`` via
|
||||
the existing data-flow edges (so the new edge would close the loop), or the
|
||||
two are the same node.
|
||||
"""
|
||||
if source_id == target_id:
|
||||
return True
|
||||
return _reachable(edges, target_id, source_id)
|
||||
|
||||
|
||||
def _entity_exists(
|
||||
entities_by_kind: dict[str, list[dict[str, Any]]], kind: str, entity_id: str
|
||||
) -> bool:
|
||||
return any(e.get("id") == entity_id for e in entities_by_kind.get(kind, []))
|
||||
|
||||
|
||||
def validate_connection(
|
||||
entities_by_kind: dict[str, list[dict[str, Any]]],
|
||||
target_kind: str,
|
||||
target_id: str,
|
||||
field: str,
|
||||
source_id: str,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Validate a proposed wiring edit before it is persisted.
|
||||
|
||||
Checks, in order: the field is a known connectable reference; the target
|
||||
exists; (when not detaching) the source exists and is of the registry's
|
||||
expected kind; and the edit would not create a dependency cycle. Returns
|
||||
``(ok, error_message)``. Detaching (empty ``source_id``) is always allowed.
|
||||
"""
|
||||
cf = next(
|
||||
(c for c in CONNECTION_SCHEMA if c.target_kind == target_kind and c.field == field),
|
||||
None,
|
||||
)
|
||||
if cf is None:
|
||||
return False, f"Unknown connection field: {target_kind}.{field}"
|
||||
if not is_editable(cf):
|
||||
# List slots (need an element index), double-nested fields, and dead
|
||||
# colour bindings can't be wired from the graph — edit via the entity
|
||||
# editor instead.
|
||||
return False, f"Field '{field}' is not editable via the graph"
|
||||
if not _entity_exists(entities_by_kind, target_kind, target_id):
|
||||
return False, f"Target entity not found: {target_id}"
|
||||
if not source_id:
|
||||
return True, None # detaching a slot is always valid
|
||||
if not _entity_exists(entities_by_kind, cf.source_kind, source_id):
|
||||
return False, f"Source {cf.source_kind} not found: {source_id}"
|
||||
# Cycle check: ignore the edge currently occupying this slot, since the
|
||||
# write replaces it.
|
||||
topo = build_topology(entities_by_kind)
|
||||
edges = [e for e in topo["edges"] if not (e["to"] == target_id and e["field"] == field)]
|
||||
if would_create_cycle(edges, source_id, target_id):
|
||||
return False, "Connection would create a dependency cycle"
|
||||
return True, None
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Shared MQTT-source validation for route handlers.
|
||||
|
||||
Both the device routes and the output-target routes accept an
|
||||
``mqtt_source_id`` that must reference an existing ``MQTTSource``. This module
|
||||
is the single source of truth for that check so the two callers cannot drift.
|
||||
"""
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
|
||||
|
||||
|
||||
def validate_mqtt_source_exists(mqtt_store: MQTTSourceStore, mqtt_source_id: str | None) -> None:
|
||||
"""Ensure a referenced MQTT source exists.
|
||||
|
||||
Empty / ``None`` is allowed (unconfigured = "first available broker").
|
||||
Raises ``HTTPException(422)`` if a non-empty id does not resolve.
|
||||
"""
|
||||
if not mqtt_source_id:
|
||||
return
|
||||
try:
|
||||
mqtt_store.get(mqtt_source_id)
|
||||
except (ValueError, EntityNotFoundError):
|
||||
raise HTTPException(status_code=422, detail=f"MQTT source {mqtt_source_id} not found")
|
||||
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
from typing import Callable
|
||||
|
||||
import numpy as np
|
||||
from starlette.websockets import WebSocket
|
||||
@@ -61,8 +61,8 @@ async def stream_capture_test(
|
||||
websocket: WebSocket,
|
||||
engine_factory: Callable,
|
||||
duration: float,
|
||||
pp_filters: Optional[list] = None,
|
||||
preview_width: Optional[int] = None,
|
||||
pp_filters: list | None = None,
|
||||
preview_width: int | None = None,
|
||||
) -> None:
|
||||
"""Run a capture test, streaming intermediate thumbnails and a final full-res frame.
|
||||
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
"""Activity-log REST API — query / filter / export / settings / clear.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
GET /api/v1/activity-log List (filterable, keyset-paginated)
|
||||
GET /api/v1/activity-log/export Streaming CSV or JSON export
|
||||
GET /api/v1/activity-log/settings Retention settings
|
||||
PUT /api/v1/activity-log/settings Update retention settings (requires non-anonymous auth)
|
||||
DELETE /api/v1/activity-log Clear all entries (requires non-anonymous auth)
|
||||
|
||||
Auth posture
|
||||
------------
|
||||
- List + read settings (``GET``): ``AuthRequired`` (loopback-anonymous is fine).
|
||||
- Export, update settings (``PUT``), and clear: ``require_authenticated()``
|
||||
(loopback-anonymous is rejected; mirrors the backup download / secret-reveal
|
||||
pattern from ``backup.py``). Updating settings can disable auditing or prune
|
||||
the trail, so it is gated like the destructive clear.
|
||||
|
||||
CSV injection
|
||||
-------------
|
||||
Cells that begin with =, +, -, @, TAB, or CR can trigger formula execution in
|
||||
spreadsheet apps (OWASP Formula Injection). ``_csv_safe`` prefixes any such cell
|
||||
with a single quote so formulas are inert. Fields already go through
|
||||
``sanitize_display`` in Phase 3 instrumentation, but the CSV writer applies its
|
||||
own guard as defence-in-depth.
|
||||
|
||||
Export generator + lock
|
||||
-----------------------
|
||||
``repo.iter_export()`` fetches rows in bounded batches, holding the DB ``_lock``
|
||||
only around each batch fetch and releasing it before yielding — so a slow or
|
||||
stalled client never blocks other DB operations. The ``StreamingResponse``
|
||||
generator is wrapped in a ``try/finally`` block so the batch generator is closed
|
||||
even when the client disconnects mid-stream.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Iterator
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from ledgrab.api.auth import AuthRequired, require_authenticated
|
||||
from ledgrab.api.dependencies import (
|
||||
get_activity_log_repo,
|
||||
get_activity_log_retention_engine,
|
||||
get_activity_recorder,
|
||||
)
|
||||
from ledgrab.api.schemas.activity_log import (
|
||||
ActivityLogPageResponse,
|
||||
ActivityLogSettingsResponse,
|
||||
UpdateActivityLogSettingsRequest,
|
||||
)
|
||||
from ledgrab.core.activity_log.recorder import ActivityRecorder, entry_to_dict
|
||||
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
|
||||
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||
from ledgrab.storage.activity_log import ActivityCategory, ActivityLogFilters, ActivitySeverity
|
||||
from ledgrab.storage.activity_log_repository import ActivityLogRepository
|
||||
|
||||
router = APIRouter(prefix="/api/v1/activity-log", tags=["Activity Log"])
|
||||
|
||||
# Hard cap on the per-request limit to prevent runaway queries.
|
||||
_MAX_LIMIT = 200
|
||||
_DEFAULT_LIMIT = 50
|
||||
|
||||
# Bounds on the text filter params so a multi-KB ``q`` / actor / entity filter
|
||||
# can't enlarge the LIKE pattern and bound params per page (FastAPI returns 422
|
||||
# on overflow). The free-text ``q`` gets a larger budget than the id filters.
|
||||
_MAX_TEXT_FILTER = 256
|
||||
_MAX_ID_FILTER = 128
|
||||
|
||||
# CSV export columns (matches entry_to_dict key order)
|
||||
_CSV_COLUMNS = [
|
||||
"id",
|
||||
"ts",
|
||||
"category",
|
||||
"action",
|
||||
"severity",
|
||||
"actor",
|
||||
"entity_type",
|
||||
"entity_id",
|
||||
"entity_name",
|
||||
"message",
|
||||
"metadata",
|
||||
]
|
||||
|
||||
# Characters that trigger formula injection in spreadsheet apps (OWASP).
|
||||
# Leading TAB and CR are also recognised triggers by Excel / Google Sheets.
|
||||
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
|
||||
|
||||
# Cap for export-cell sanitization. Effectively no truncation (a single audit
|
||||
# field never approaches this) — we reuse sanitize_display only to strip
|
||||
# NUL/control/ANSI from CSV cells, not to shorten them.
|
||||
_EXPORT_CELL_MAXLEN = 1_000_000
|
||||
|
||||
|
||||
def _csv_safe(value: str) -> str:
|
||||
"""Prefix formula-injection triggers with a literal single-quote.
|
||||
|
||||
A cell starting with =, +, -, or @ can execute as a formula in Excel /
|
||||
Google Sheets. OWASP recommends prepending a single quote to neutralise it.
|
||||
"""
|
||||
if value and value[0] in _FORMULA_PREFIXES:
|
||||
return "'" + value
|
||||
return value
|
||||
|
||||
|
||||
def _redact_for_anon(entry_dict: dict, auth_label: str) -> dict:
|
||||
"""Redact the source-IP metadata for anonymous (loopback) callers.
|
||||
|
||||
The streaming export is gated by ``require_authenticated`` precisely because
|
||||
the log can contain client IPs (e.g. ``auth.rejected`` / ``auth.ws_connected``
|
||||
store ``metadata.client``). The list endpoint allows loopback-anonymous
|
||||
callers, so to keep the posture consistent we mask that one field for the
|
||||
``"anonymous"`` label rather than handing it back what export withholds.
|
||||
"""
|
||||
if auth_label != "anonymous":
|
||||
return entry_dict
|
||||
meta = entry_dict.get("metadata")
|
||||
if isinstance(meta, dict) and "client" in meta:
|
||||
return {**entry_dict, "metadata": {**meta, "client": "[redacted]"}}
|
||||
return entry_dict
|
||||
|
||||
|
||||
def _build_filters(
|
||||
categories: list[str] | None,
|
||||
severities: list[str] | None,
|
||||
actor: str | None,
|
||||
entity_type: str | None,
|
||||
entity_id: str | None,
|
||||
since: datetime | None,
|
||||
until: datetime | None,
|
||||
q: str | None,
|
||||
) -> ActivityLogFilters:
|
||||
"""Assemble an ``ActivityLogFilters`` dataclass from query parameters."""
|
||||
return ActivityLogFilters(
|
||||
categories=categories or None,
|
||||
severities=severities or None,
|
||||
actor=actor or None,
|
||||
entity_type=entity_type or None,
|
||||
entity_id=entity_id or None,
|
||||
since=since,
|
||||
until=until,
|
||||
message_like=q or None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/v1/activity-log — list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("", response_model=ActivityLogPageResponse, summary="List activity-log entries")
|
||||
def list_activity_log(
|
||||
auth: AuthRequired,
|
||||
repo: ActivityLogRepository = Depends(get_activity_log_repo),
|
||||
# ── Filters ────────────────────────────────────────────────────────────
|
||||
categories: Annotated[
|
||||
list[str] | None,
|
||||
Query(
|
||||
description=(
|
||||
"Filter by category (repeatable or comma-separated). "
|
||||
"Values: auth, device, entity, capture, system"
|
||||
)
|
||||
),
|
||||
] = None,
|
||||
severities: Annotated[
|
||||
list[str] | None,
|
||||
Query(description="Filter by severity (repeatable). Values: info, warning, error"),
|
||||
] = None,
|
||||
actor: Annotated[
|
||||
str | None,
|
||||
Query(max_length=_MAX_ID_FILTER, description="Filter by actor label (exact match)"),
|
||||
] = None,
|
||||
entity_type: Annotated[
|
||||
str | None,
|
||||
Query(max_length=_MAX_ID_FILTER, description="Filter by entity type (exact match)"),
|
||||
] = None,
|
||||
entity_id: Annotated[
|
||||
str | None,
|
||||
Query(max_length=_MAX_ID_FILTER, description="Filter by entity id (exact match)"),
|
||||
] = None,
|
||||
since: Annotated[
|
||||
datetime | None,
|
||||
Query(description="Return entries at or after this ISO-8601 datetime"),
|
||||
] = None,
|
||||
until: Annotated[
|
||||
datetime | None,
|
||||
Query(description="Return entries at or before this ISO-8601 datetime"),
|
||||
] = None,
|
||||
q: Annotated[
|
||||
str | None,
|
||||
Query(
|
||||
max_length=_MAX_TEXT_FILTER,
|
||||
description="Free-text search in the message field (substring)",
|
||||
),
|
||||
] = None,
|
||||
# ── Pagination ─────────────────────────────────────────────────────────
|
||||
before_seq: Annotated[
|
||||
int | None,
|
||||
Query(
|
||||
description=(
|
||||
"Keyset cursor: pass the 'next_before_seq' from the previous page "
|
||||
"to get the following (older) page. Omit for the first (newest) page."
|
||||
)
|
||||
),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
Query(
|
||||
ge=1,
|
||||
le=_MAX_LIMIT,
|
||||
description=f"Max entries per page (default {_DEFAULT_LIMIT}, max {_MAX_LIMIT})",
|
||||
),
|
||||
] = _DEFAULT_LIMIT,
|
||||
) -> ActivityLogPageResponse:
|
||||
"""Return the newest matching entries, oldest-first within the page.
|
||||
|
||||
Keyset pagination: the response includes ``next_before_seq`` — pass it
|
||||
as ``before_seq`` in the next request to get the next (older) page.
|
||||
The ``total`` field is the count of all entries matching the current
|
||||
filters across all pages.
|
||||
"""
|
||||
filters = _build_filters(categories, severities, actor, entity_type, entity_id, since, until, q)
|
||||
|
||||
# Fetch limit+1 rows to detect whether an older page exists.
|
||||
#
|
||||
# query() fetches DESC internally (newest-first) then reverses to ascending.
|
||||
# With limit+1, the result is ascending: [oldest_probe, ..., newest].
|
||||
# When we got exactly limit+1 rows, has_more is True and the probe row
|
||||
# (index 0 — the oldest) is the extra one. We keep the newest `limit` rows
|
||||
# by slicing [1:], which is the actual page content for the client.
|
||||
# When we got <= limit rows, this is the last page and all rows are included.
|
||||
effective_limit = min(limit, _MAX_LIMIT)
|
||||
# query_with_seq returns (seq, entry) ascending (oldest-first within page),
|
||||
# so the seq is already in hand — no extra get_seq_for_id round-trip.
|
||||
rows_plus = repo.query_with_seq(filters, before_seq=before_seq, limit=effective_limit + 1)
|
||||
has_more = len(rows_plus) > effective_limit
|
||||
# When over-fetched, drop the oldest probe row (index 0) and keep the newest.
|
||||
rows = rows_plus[1:] if has_more else rows_plus
|
||||
|
||||
total = repo.count(filters)
|
||||
|
||||
# next_before_seq: the seq of the oldest entry on this page (rows[0]).
|
||||
# The next request passes before_seq=X to get entries with seq < X.
|
||||
next_before_seq: int | None = rows[0][0] if (has_more and rows) else None
|
||||
|
||||
return ActivityLogPageResponse(
|
||||
entries=[_redact_for_anon(entry_to_dict(e), auth) for _seq, e in rows], # type: ignore[arg-type]
|
||||
next_before_seq=next_before_seq,
|
||||
has_more=has_more,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/v1/activity-log/export — streaming export (CSV or JSON)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _export_csv_generator(
|
||||
repo: ActivityLogRepository,
|
||||
filters: ActivityLogFilters,
|
||||
) -> Iterator[bytes]:
|
||||
"""Yield UTF-8-encoded CSV chunks one row at a time.
|
||||
|
||||
The generator wraps ``repo.iter_export()`` in a ``try/finally`` so the DB
|
||||
lock is released even on early client disconnect (which triggers
|
||||
``GeneratorExit``).
|
||||
"""
|
||||
gen = repo.iter_export(filters)
|
||||
try:
|
||||
# Header
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(_CSV_COLUMNS)
|
||||
yield buf.getvalue().encode("utf-8")
|
||||
|
||||
for entry in gen:
|
||||
d = entry_to_dict(entry)
|
||||
row = []
|
||||
for col in _CSV_COLUMNS:
|
||||
if col == "metadata":
|
||||
# json.dumps escapes control chars (<0x20) as \uXXXX, so the
|
||||
# metadata cell can't carry raw NUL/CR/ANSI into the file.
|
||||
cell = json.dumps(d.get(col) or {})
|
||||
else:
|
||||
# Defense-in-depth: strip NUL/control/ANSI from string cells
|
||||
# at the export boundary so a (current or future) un-sanitized
|
||||
# call site can't leak control chars into the CSV. csv.writer
|
||||
# quotes embedded newlines but does not strip control chars.
|
||||
cell = sanitize_display(str(d.get(col, "") or ""), maxlen=_EXPORT_CELL_MAXLEN)
|
||||
row.append(_csv_safe(cell))
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(row)
|
||||
yield buf.getvalue().encode("utf-8")
|
||||
finally:
|
||||
gen.close()
|
||||
|
||||
|
||||
def _export_json_generator(
|
||||
repo: ActivityLogRepository,
|
||||
filters: ActivityLogFilters,
|
||||
) -> Iterator[bytes]:
|
||||
"""Yield a streamed JSON array, one entry per chunk.
|
||||
|
||||
Format: ``[\\n{entry},\\n{entry},\\n...]\\n``
|
||||
The generator wraps ``repo.iter_export()`` in a ``try/finally`` so the DB
|
||||
lock is released even on early client disconnect.
|
||||
"""
|
||||
gen = repo.iter_export(filters)
|
||||
try:
|
||||
first = True
|
||||
yield b"[\n"
|
||||
for entry in gen:
|
||||
d = entry_to_dict(entry)
|
||||
chunk = json.dumps(d, ensure_ascii=False, default=str)
|
||||
if first:
|
||||
yield chunk.encode("utf-8")
|
||||
first = False
|
||||
else:
|
||||
yield b",\n" + chunk.encode("utf-8")
|
||||
yield b"\n]\n"
|
||||
finally:
|
||||
gen.close()
|
||||
|
||||
|
||||
@router.get("/export", summary="Export activity-log entries (streaming CSV or JSON)")
|
||||
def export_activity_log(
|
||||
auth: AuthRequired,
|
||||
repo: ActivityLogRepository = Depends(get_activity_log_repo),
|
||||
# ── Format ────────────────────────────────────────────────────────────
|
||||
format: Annotated[
|
||||
str,
|
||||
Query(description="Export format: 'csv' or 'json'"),
|
||||
] = "csv",
|
||||
# ── Same filters as list ───────────────────────────────────────────────
|
||||
categories: Annotated[list[str] | None, Query()] = None,
|
||||
severities: Annotated[list[str] | None, Query()] = None,
|
||||
actor: Annotated[str | None, Query(max_length=_MAX_ID_FILTER)] = None,
|
||||
entity_type: Annotated[str | None, Query(max_length=_MAX_ID_FILTER)] = None,
|
||||
entity_id: Annotated[str | None, Query(max_length=_MAX_ID_FILTER)] = None,
|
||||
since: Annotated[datetime | None, Query()] = None,
|
||||
until: Annotated[datetime | None, Query()] = None,
|
||||
q: Annotated[str | None, Query(max_length=_MAX_TEXT_FILTER)] = None,
|
||||
) -> StreamingResponse:
|
||||
"""Stream all matching entries as CSV or JSON.
|
||||
|
||||
Requires a non-anonymous API key (loopback-anonymous access is rejected
|
||||
because the log may contain IP addresses and entity names).
|
||||
"""
|
||||
require_authenticated(auth)
|
||||
|
||||
if format not in ("csv", "json"):
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="'format' must be 'csv' or 'json'",
|
||||
)
|
||||
|
||||
filters = _build_filters(categories, severities, actor, entity_type, entity_id, since, until, q)
|
||||
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
|
||||
|
||||
if format == "csv":
|
||||
filename = f"activity-log-{timestamp}.csv"
|
||||
media_type = "text/csv; charset=utf-8"
|
||||
generator = _export_csv_generator(repo, filters)
|
||||
else:
|
||||
filename = f"activity-log-{timestamp}.json"
|
||||
media_type = "application/json"
|
||||
generator = _export_json_generator(repo, filters)
|
||||
|
||||
return StreamingResponse(
|
||||
generator,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/v1/activity-log/settings
|
||||
# PUT /api/v1/activity-log/settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/settings",
|
||||
response_model=ActivityLogSettingsResponse,
|
||||
summary="Get activity-log retention settings",
|
||||
)
|
||||
def get_activity_log_settings(
|
||||
_: AuthRequired,
|
||||
engine: ActivityLogRetentionEngine = Depends(get_activity_log_retention_engine),
|
||||
) -> ActivityLogSettingsResponse:
|
||||
"""Return the current activity-log retention settings."""
|
||||
return ActivityLogSettingsResponse(**engine.get_settings())
|
||||
|
||||
|
||||
@router.put(
|
||||
"/settings",
|
||||
response_model=ActivityLogSettingsResponse,
|
||||
summary="Update activity-log retention settings",
|
||||
)
|
||||
async def update_activity_log_settings(
|
||||
auth: AuthRequired,
|
||||
body: UpdateActivityLogSettingsRequest,
|
||||
engine: ActivityLogRetentionEngine = Depends(get_activity_log_retention_engine),
|
||||
) -> ActivityLogSettingsResponse:
|
||||
"""Update the activity-log retention settings (applied immediately).
|
||||
|
||||
Requires a non-anonymous API key (loopback-anonymous access is rejected)
|
||||
because disabling the log or pruning retention is equivalent in impact to
|
||||
clearing the audit trail.
|
||||
|
||||
Setting ``enabled=false`` records an audit entry BEFORE the flag takes
|
||||
effect so the last entry in the log shows who disabled recording.
|
||||
"""
|
||||
require_authenticated(auth)
|
||||
result = await engine.update_settings(
|
||||
enabled=body.enabled,
|
||||
max_days=body.max_days,
|
||||
max_entries=body.max_entries,
|
||||
)
|
||||
return ActivityLogSettingsResponse(**result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /api/v1/activity-log — clear
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.delete("", summary="Clear all activity-log entries")
|
||||
def clear_activity_log(
|
||||
auth: AuthRequired,
|
||||
repo: ActivityLogRepository = Depends(get_activity_log_repo),
|
||||
recorder: ActivityRecorder = Depends(get_activity_recorder),
|
||||
) -> dict:
|
||||
"""Delete all activity-log entries.
|
||||
|
||||
Requires a non-anonymous API key (loopback-anonymous access is rejected).
|
||||
The clear operation itself is audited — a ``system/activity_log_cleared``
|
||||
entry is recorded AFTER the wipe, so the log shows who cleared it and how
|
||||
many rows were removed.
|
||||
|
||||
Returns ``{"deleted": <count>}``.
|
||||
"""
|
||||
require_authenticated(auth)
|
||||
|
||||
deleted = repo.clear()
|
||||
|
||||
# Record the clear action (best-effort — recorder never raises).
|
||||
recorder.record(
|
||||
category=ActivityCategory.SYSTEM,
|
||||
action="activity_log.cleared",
|
||||
severity=ActivitySeverity.INFO,
|
||||
actor=auth,
|
||||
message=f"Activity log cleared ({deleted} entries removed)",
|
||||
metadata={"deleted_count": deleted},
|
||||
)
|
||||
|
||||
return {"deleted": deleted}
|
||||
@@ -15,7 +15,7 @@ from ledgrab.api.schemas.assets import (
|
||||
from ledgrab.config import get_config
|
||||
from ledgrab.storage.asset_store import AssetStore
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils import get_logger, read_upload_capped
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -93,10 +93,11 @@ async def upload_asset(
|
||||
config = get_config()
|
||||
max_size = getattr(getattr(config, "assets", None), "max_file_size_mb", 50) * 1024 * 1024
|
||||
|
||||
data = await file.read()
|
||||
if len(data) > max_size:
|
||||
try:
|
||||
data = await read_upload_capped(file, max_size)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
status_code=413,
|
||||
detail=f"File too large (max {max_size // (1024 * 1024)} MB)",
|
||||
)
|
||||
|
||||
@@ -142,6 +143,8 @@ async def update_asset(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
tags=body.tags,
|
||||
icon=body.icon,
|
||||
icon_color=body.icon_color,
|
||||
)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
|
||||
|
||||
@@ -36,6 +36,8 @@ def _apt_to_response(t) -> AudioProcessingTemplateResponse:
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=t.tags,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -73,6 +75,8 @@ async def create_audio_processing_template(
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("audio_processing_template", "created", template.id)
|
||||
return _apt_to_response(template)
|
||||
@@ -129,6 +133,8 @@ async def update_audio_processing_template(
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("audio_processing_template", "updated", template_id)
|
||||
# Hot-update: rebuild filter pipelines for running streams using this template
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Audio source routes: CRUD for audio sources + real-time test WebSocket."""
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated, Optional
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||
@@ -46,6 +46,8 @@ _RESPONSE_MAP = {
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
device_index=s.device_index,
|
||||
is_loopback=s.is_loopback,
|
||||
audio_template_id=s.audio_template_id,
|
||||
@@ -57,6 +59,8 @@ _RESPONSE_MAP = {
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
audio_source_id=s.audio_source_id,
|
||||
audio_processing_template_id=s.audio_processing_template_id,
|
||||
),
|
||||
@@ -75,6 +79,8 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
|
||||
tags=source.tags,
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
icon=getattr(source, "icon", "") or "",
|
||||
icon_color=getattr(source, "icon_color", "") or "",
|
||||
device_index=getattr(source, "device_index", -1),
|
||||
is_loopback=getattr(source, "is_loopback", True),
|
||||
audio_template_id=getattr(source, "audio_template_id", None),
|
||||
@@ -85,7 +91,7 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
|
||||
@router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"])
|
||||
async def list_audio_sources(
|
||||
_auth: AuthRequired,
|
||||
source_type: Optional[str] = Query(
|
||||
source_type: str | None = Query(
|
||||
None, description="Filter by source_type: capture or processed"
|
||||
),
|
||||
store: AudioSourceStore = Depends(get_audio_source_store),
|
||||
@@ -176,6 +182,12 @@ async def delete_audio_source(
|
||||
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||
):
|
||||
"""Delete an audio source."""
|
||||
_entity_name: str | None = None
|
||||
try:
|
||||
_entity_name = store.get_source(source_id).name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Check if any CSS entities reference this audio source
|
||||
from ledgrab.storage.color_strip_source import AudioColorStripSource
|
||||
@@ -188,7 +200,7 @@ async def delete_audio_source(
|
||||
raise ValueError(f"Cannot delete: referenced by color strip source '{css.name}'")
|
||||
|
||||
store.delete_source(source_id)
|
||||
fire_entity_event("audio_source", "deleted", source_id)
|
||||
fire_entity_event("audio_source", "deleted", source_id, entity_name=_entity_name)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ async def list_audio_templates(
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
)
|
||||
for t in templates
|
||||
]
|
||||
@@ -81,6 +83,8 @@ async def create_audio_template(
|
||||
engine_config=data.engine_config,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("audio_template", "created", template.id)
|
||||
return AudioTemplateResponse(
|
||||
@@ -92,6 +96,8 @@ async def create_audio_template(
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
description=template.description,
|
||||
icon=getattr(template, "icon", "") or "",
|
||||
icon_color=getattr(template, "icon_color", "") or "",
|
||||
)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
@@ -127,6 +133,8 @@ async def get_audio_template(
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -150,6 +158,8 @@ async def update_audio_template(
|
||||
engine_config=data.engine_config,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("audio_template", "updated", template_id)
|
||||
return AudioTemplateResponse(
|
||||
@@ -161,6 +171,8 @@ async def update_audio_template(
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
@@ -12,27 +12,35 @@ from ledgrab.api.dependencies import (
|
||||
get_scene_preset_store,
|
||||
)
|
||||
from ledgrab.api.schemas.automations import (
|
||||
ActionSchema,
|
||||
AutomationCreate,
|
||||
AutomationListResponse,
|
||||
AutomationResponse,
|
||||
AutomationTriggerResponse,
|
||||
AutomationUpdate,
|
||||
RuleSchema,
|
||||
)
|
||||
from ledgrab.core.automations.automation_engine import AutomationEngine
|
||||
from ledgrab.storage.automation import (
|
||||
Action,
|
||||
ApplicationRule,
|
||||
DisplayStateRule,
|
||||
HomeAssistantRule,
|
||||
HTTPPollRule,
|
||||
ManualTriggerRule,
|
||||
MQTTRule,
|
||||
Rule,
|
||||
SolarRule,
|
||||
StartupRule,
|
||||
SystemIdleRule,
|
||||
TimeOfDayRule,
|
||||
WebhookAction,
|
||||
WebhookRule,
|
||||
)
|
||||
from ledgrab.storage.automation_store import AutomationStore
|
||||
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.safe_source import validate_polling_url
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -51,6 +59,22 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
|
||||
"time_of_day": lambda: TimeOfDayRule(
|
||||
start_time=s.start_time or "00:00",
|
||||
end_time=s.end_time or "23:59",
|
||||
days_of_week=s.days_of_week or [],
|
||||
timezone=s.timezone or "",
|
||||
),
|
||||
# SolarRule.from_dict validates events, clamps offsets/coords, and
|
||||
# filters weekdays — route the raw schema values through it.
|
||||
"solar": lambda: SolarRule.from_dict(
|
||||
{
|
||||
"start_event": s.start_event,
|
||||
"start_offset_minutes": s.start_offset_minutes,
|
||||
"end_event": s.end_event,
|
||||
"end_offset_minutes": s.end_offset_minutes,
|
||||
"latitude": s.latitude,
|
||||
"longitude": s.longitude,
|
||||
"days_of_week": s.days_of_week or [],
|
||||
"timezone": s.timezone or "",
|
||||
}
|
||||
),
|
||||
"system_idle": lambda: SystemIdleRule(
|
||||
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
|
||||
@@ -69,12 +93,18 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
|
||||
token=s.token or secrets.token_hex(16),
|
||||
),
|
||||
"startup": lambda: StartupRule(),
|
||||
"manual_trigger": lambda: ManualTriggerRule(),
|
||||
"home_assistant": lambda: HomeAssistantRule(
|
||||
ha_source_id=s.ha_source_id or "",
|
||||
entity_id=s.entity_id or "",
|
||||
state=s.state or "",
|
||||
match_mode=s.match_mode or "exact",
|
||||
),
|
||||
"http_poll": lambda: HTTPPollRule(
|
||||
value_source_id=s.value_source_id or "",
|
||||
operator=s.operator or "equals",
|
||||
value=s.value or "",
|
||||
),
|
||||
}
|
||||
factory = _SCHEMA_TO_RULE.get(s.rule_type)
|
||||
if factory is None:
|
||||
@@ -87,6 +117,43 @@ def _rule_to_schema(r: Rule) -> RuleSchema:
|
||||
return RuleSchema(**d)
|
||||
|
||||
|
||||
def _action_from_schema(s: ActionSchema) -> Action:
|
||||
"""Build a domain Action from its request schema, validating the webhook URL.
|
||||
|
||||
The SSRF gate runs here (save time) AND again at fire time, closing the
|
||||
DNS-rebinding window. A bad/blocked URL rejects the whole save with 400.
|
||||
"""
|
||||
if s.action_type != "webhook":
|
||||
raise ValueError(f"Unknown action type: {s.action_type}")
|
||||
url = (s.webhook_url or "").strip()
|
||||
if not url:
|
||||
raise ValueError("webhook action requires a webhook_url")
|
||||
method = (s.method or "POST").upper()
|
||||
if method not in ("POST", "PUT", "GET"):
|
||||
raise ValueError(f"Invalid webhook method: {method}. Must be POST, PUT or GET.")
|
||||
fire_on = s.fire_on or "activate"
|
||||
if fire_on not in ("activate", "deactivate", "both"):
|
||||
raise ValueError(f"Invalid fire_on: {fire_on}. Must be activate, deactivate or both.")
|
||||
# content_type is emitted verbatim as the outbound Content-Type header — reject
|
||||
# control chars (CR/LF) so it can't be used to inject additional HTTP headers.
|
||||
content_type = (s.content_type or "application/json").strip()
|
||||
if len(content_type) > 128 or any(ord(c) < 0x20 or ord(c) > 0x7E for c in content_type):
|
||||
raise ValueError("Invalid content_type: control or non-ASCII characters are not allowed.")
|
||||
# Raises HTTPException(400) on a blocked/loopback/metadata target.
|
||||
validate_polling_url(url)
|
||||
return WebhookAction(
|
||||
webhook_url=url,
|
||||
method=method,
|
||||
body_template=s.body_template or "",
|
||||
content_type=content_type,
|
||||
fire_on=fire_on,
|
||||
)
|
||||
|
||||
|
||||
def _action_to_schema(a: Action) -> ActionSchema:
|
||||
return ActionSchema(**a.to_dict())
|
||||
|
||||
|
||||
def _automation_to_response(
|
||||
automation, engine: AutomationEngine, request: Request = None
|
||||
) -> AutomationResponse:
|
||||
@@ -122,6 +189,9 @@ def _automation_to_response(
|
||||
last_activated_at=state.get("last_activated_at"),
|
||||
last_deactivated_at=state.get("last_deactivated_at"),
|
||||
tags=automation.tags,
|
||||
actions=[_action_to_schema(a) for a in getattr(automation, "actions", [])],
|
||||
icon=getattr(automation, "icon", "") or "",
|
||||
icon_color=getattr(automation, "icon_color", "") or "",
|
||||
created_at=automation.created_at,
|
||||
updated_at=automation.updated_at,
|
||||
)
|
||||
@@ -176,6 +246,7 @@ async def create_automation(
|
||||
|
||||
try:
|
||||
rules = [_rule_from_schema(r) for r in data.rules]
|
||||
actions = [_action_from_schema(a) for a in data.actions]
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
@@ -191,6 +262,9 @@ async def create_automation(
|
||||
deactivation_mode=data.deactivation_mode,
|
||||
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
|
||||
tags=data.tags,
|
||||
actions=actions,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
|
||||
if automation.enabled:
|
||||
@@ -271,6 +345,13 @@ async def update_automation(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
actions = None
|
||||
if data.actions is not None:
|
||||
try:
|
||||
actions = [_action_from_schema(a) for a in data.actions]
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
try:
|
||||
# If disabling, deactivate first
|
||||
if data.enabled is False:
|
||||
@@ -285,6 +366,9 @@ async def update_automation(
|
||||
rules=rules,
|
||||
deactivation_mode=data.deactivation_mode,
|
||||
tags=data.tags,
|
||||
actions=actions,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
if data.scene_preset_id is not None:
|
||||
update_kwargs["scene_preset_id"] = data.scene_preset_id
|
||||
@@ -315,6 +399,12 @@ async def delete_automation(
|
||||
engine: AutomationEngine = Depends(get_automation_engine),
|
||||
):
|
||||
"""Delete an automation."""
|
||||
_entity_name: str | None = None
|
||||
try:
|
||||
_entity_name = store.get_automation(automation_id).name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Deactivate first
|
||||
await engine.deactivate_if_active(automation_id)
|
||||
|
||||
@@ -323,7 +413,7 @@ async def delete_automation(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
fire_entity_event("automation", "deleted", automation_id)
|
||||
fire_entity_event("automation", "deleted", automation_id, entity_name=_entity_name)
|
||||
|
||||
|
||||
# ===== Enable/Disable =====
|
||||
@@ -374,3 +464,37 @@ async def disable_automation(
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
return _automation_to_response(automation, engine, request)
|
||||
|
||||
|
||||
# ===== Manual trigger =====
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/automations/{automation_id}/trigger",
|
||||
response_model=AutomationTriggerResponse,
|
||||
tags=["Automations"],
|
||||
)
|
||||
async def trigger_automation(
|
||||
automation_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: AutomationStore = Depends(get_automation_store),
|
||||
engine: AutomationEngine = Depends(get_automation_engine),
|
||||
):
|
||||
"""Manually fire an automation.
|
||||
|
||||
Evaluates the automation's rules with its manual trigger satisfied — so it
|
||||
"still checks all of the rules" under the automation's ``rule_logic`` — and,
|
||||
if it should activate, applies its scene once. Independent of the ``enabled``
|
||||
flag (that gates only the background evaluation loop).
|
||||
"""
|
||||
try:
|
||||
automation = store.get_automation(automation_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
try:
|
||||
status, errors = await engine.fire_manual_trigger(automation)
|
||||
except Exception as e: # noqa: BLE001 — surface a structured error, never a bare 500
|
||||
logger.error("Manual trigger failed for automation %s: %s", automation_id, e)
|
||||
return AutomationTriggerResponse(status="error", errors=[str(e)])
|
||||
return AutomationTriggerResponse(status=status, errors=errors)
|
||||
|
||||
@@ -11,6 +11,7 @@ import sys
|
||||
import threading
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -26,40 +27,88 @@ from ledgrab.api.schemas.system import (
|
||||
)
|
||||
from ledgrab.config import get_config
|
||||
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
||||
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||
from ledgrab.storage.asset_store import AssetStore
|
||||
from ledgrab.storage.database import Database, freeze_writes
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils import get_logger, read_upload_capped
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _record_system(action: str, message: str, metadata: dict | None = None) -> None:
|
||||
"""Best-effort audit record for a system-level event."""
|
||||
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||
|
||||
rec = get_module_recorder()
|
||||
if rec is not None:
|
||||
rec.record(
|
||||
category=ActivityCategory.SYSTEM,
|
||||
action=action,
|
||||
severity=ActivitySeverity.INFO,
|
||||
message=message,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
|
||||
_SERVER_DIR = Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
def _schedule_restart() -> None:
|
||||
"""Spawn a restart script after a short delay so the HTTP response completes."""
|
||||
"""Spawn a restart script after a short delay so the HTTP response completes.
|
||||
|
||||
def _restart():
|
||||
stdout/stderr of the spawned script are redirected to ``<server>/restart.log``
|
||||
so a silent failure (PowerShell not on PATH, restart.ps1 erroring, etc.)
|
||||
leaves evidence on disk instead of vanishing into a detached child.
|
||||
"""
|
||||
|
||||
def _restart() -> None:
|
||||
import time
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
# Annotated as ``dict[str, Any]`` because the value union spans
|
||||
# int flags (Windows ``creationflags``) and bool (POSIX
|
||||
# ``start_new_session``); a narrower union confuses ``**`` unpacking.
|
||||
popen_kwargs: dict[str, Any]
|
||||
if sys.platform == "win32":
|
||||
subprocess.Popen(
|
||||
[
|
||||
"powershell",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(_SERVER_DIR / "restart.ps1"),
|
||||
],
|
||||
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||
)
|
||||
script = _SERVER_DIR / "restart.ps1"
|
||||
cmd = ["powershell", "-ExecutionPolicy", "Bypass", "-File", str(script)]
|
||||
popen_kwargs = {
|
||||
"creationflags": (
|
||||
subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
),
|
||||
}
|
||||
else:
|
||||
subprocess.Popen(
|
||||
["bash", str(_SERVER_DIR / "restart.sh")],
|
||||
start_new_session=True,
|
||||
script = _SERVER_DIR / "restart.sh"
|
||||
cmd = ["bash", str(script)]
|
||||
popen_kwargs = {"start_new_session": True}
|
||||
|
||||
if not script.is_file():
|
||||
logger.error("Restart script missing: %s", script)
|
||||
return
|
||||
|
||||
log_path = _SERVER_DIR / "restart.log"
|
||||
try:
|
||||
# Open in append mode so multiple restarts accumulate; the child
|
||||
# owns its own duped handle, so closing here in the parent is safe.
|
||||
with open(log_path, "ab") as log_file:
|
||||
log_file.write(
|
||||
f"\n--- restart spawned at {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n".encode()
|
||||
)
|
||||
log_file.flush()
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
**popen_kwargs,
|
||||
)
|
||||
logger.info("Restart script launched: %s (PID %s, log %s)", cmd[0], proc.pid, log_path)
|
||||
except OSError as e:
|
||||
logger.error("Failed to launch restart script %s: %s", script, e, exc_info=True)
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error launching restart script: %s", e, exc_info=True)
|
||||
|
||||
threading.Thread(target=_restart, daemon=True).start()
|
||||
|
||||
@@ -111,6 +160,8 @@ def backup_config(
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
|
||||
filename = f"ledgrab-backup-{timestamp}.zip"
|
||||
|
||||
_record_system("backup.created", f"Backup downloaded: {filename}", {"filename": filename})
|
||||
|
||||
return StreamingResponse(
|
||||
zip_buffer,
|
||||
media_type="application/zip",
|
||||
@@ -133,9 +184,11 @@ async def restore_config(
|
||||
because restore replaces all configuration including secrets).
|
||||
"""
|
||||
require_authenticated(auth)
|
||||
raw = await file.read()
|
||||
if len(raw) > 200 * 1024 * 1024: # 200 MB limit (ZIP may contain assets)
|
||||
raise HTTPException(status_code=400, detail="Backup file too large (max 200 MB)")
|
||||
_MAX_BACKUP_BYTES = 200 * 1024 * 1024 # 200 MB (ZIP may contain assets)
|
||||
try:
|
||||
raw = await read_upload_capped(file, _MAX_BACKUP_BYTES)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=413, detail="Backup file too large (max 200 MB)")
|
||||
|
||||
if len(raw) < 100:
|
||||
raise HTTPException(status_code=400, detail="File too small to be a valid backup")
|
||||
@@ -209,6 +262,7 @@ async def restore_config(
|
||||
|
||||
freeze_writes()
|
||||
logger.info("Database restored from uploaded backup. Scheduling restart...")
|
||||
_record_system("backup.restored", "Database restored from uploaded backup")
|
||||
_schedule_restart()
|
||||
|
||||
return RestoreResponse(
|
||||
@@ -223,6 +277,7 @@ def restart_server(_: AuthRequired):
|
||||
"""Schedule a server restart and return immediately."""
|
||||
from ledgrab.server_ref import _broadcast_restarting
|
||||
|
||||
_record_system("server.restarting", "Server restart requested by user")
|
||||
_broadcast_restarting()
|
||||
_schedule_restart()
|
||||
return {"status": "restarting"}
|
||||
@@ -233,6 +288,7 @@ def shutdown_server(_: AuthRequired):
|
||||
"""Gracefully shut down the server."""
|
||||
from ledgrab.server_ref import request_shutdown
|
||||
|
||||
_record_system("server.shutdown_requested", "Server shutdown requested by user")
|
||||
request_shutdown()
|
||||
return {"status": "shutting_down"}
|
||||
|
||||
@@ -266,11 +322,17 @@ async def update_auto_backup_settings(
|
||||
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||
):
|
||||
"""Update auto-backup settings (enable/disable, interval, max backups)."""
|
||||
return await engine.update_settings(
|
||||
result = await engine.update_settings(
|
||||
enabled=body.enabled,
|
||||
interval_hours=body.interval_hours,
|
||||
max_backups=body.max_backups,
|
||||
)
|
||||
_record_system(
|
||||
"settings.changed",
|
||||
f"Auto-backup settings updated (enabled={body.enabled})",
|
||||
{"setting_key": "auto_backup", "enabled": body.enabled},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/v1/system/auto-backup/trigger", tags=["System"])
|
||||
@@ -331,4 +393,5 @@ async def delete_saved_backup(
|
||||
engine.delete_backup(filename)
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
_record_system("backup.deleted", f"Saved backup deleted: {filename}", {"filename": filename})
|
||||
return {"status": "deleted", "filename": filename}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Calibration session and solver API routes.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
POST /api/v1/calibration/session
|
||||
Start a calibration session on a device (stops any running target on that
|
||||
device and remembers it for restore on stop).
|
||||
|
||||
POST /api/v1/calibration/session/position
|
||||
Advance the chase pixel to a specific LED index on the active device.
|
||||
|
||||
POST /api/v1/calibration/session/stop
|
||||
End the session: clear the device to black and restore the prior target.
|
||||
|
||||
POST /api/v1/calibration/session/cancel
|
||||
Alias for stop (does not apply any solved calibration).
|
||||
|
||||
GET /api/v1/calibration/session/state
|
||||
Return the current session state (active, device, last_activity, …).
|
||||
|
||||
POST /api/v1/calibration/solve
|
||||
Pure-logic: solve a CalibrationConfig from 4 corner tap indices.
|
||||
Does NOT persist — the caller must follow up with
|
||||
``PUT /api/v1/color-strip-sources/{id}`` to persist.
|
||||
|
||||
Persist path
|
||||
------------
|
||||
The existing ``PUT /api/v1/color-strip-sources/{id}`` already accepts a
|
||||
``calibration`` field on ``PictureCSSUpdate`` / ``PictureAdvancedCSSUpdate``
|
||||
and hot-reloads running streams automatically (see
|
||||
``api/routes/color_strip_sources/crud.py``). There is NO duplicate endpoint
|
||||
here. Phase 3 UI calls the existing PUT to persist.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.api.dependencies import get_processor_manager
|
||||
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||
from ledgrab.api.schemas.calibration import (
|
||||
CalibrationSessionPositionRequest,
|
||||
CalibrationSessionStartRequest,
|
||||
CalibrationSessionStateResponse,
|
||||
CalibrationSolveRequest,
|
||||
CalibrationSolvedResponse,
|
||||
)
|
||||
from ledgrab.core.capture.calibration import solve_calibration
|
||||
from ledgrab.core.capture.calibration_session import get_calibration_session
|
||||
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Session endpoints ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/calibration/session",
|
||||
response_model=CalibrationSessionStateResponse,
|
||||
tags=["Calibration"],
|
||||
status_code=201,
|
||||
)
|
||||
async def start_calibration_session(
|
||||
body: CalibrationSessionStartRequest,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
) -> CalibrationSessionStateResponse:
|
||||
"""Start a calibration session on a device.
|
||||
|
||||
Stops any target currently processing on that device (it will be restored
|
||||
when the session ends). Only one session can be active at a time; starting
|
||||
a new one terminates the previous one first.
|
||||
"""
|
||||
session = get_calibration_session()
|
||||
try:
|
||||
await session.start(body.device_id, manager)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
except Exception as exc:
|
||||
logger.error("Failed to start calibration session: %s", exc, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||
|
||||
rec = get_module_recorder()
|
||||
if rec is not None:
|
||||
rec.record(
|
||||
category=ActivityCategory.SYSTEM,
|
||||
action="calibration.started",
|
||||
severity=ActivitySeverity.INFO,
|
||||
entity_type="device",
|
||||
entity_id=body.device_id,
|
||||
message=f"Calibration session started for device '{body.device_id}'",
|
||||
)
|
||||
|
||||
return CalibrationSessionStateResponse(**session.get_state())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/calibration/session/position",
|
||||
response_model=CalibrationSessionStateResponse,
|
||||
tags=["Calibration"],
|
||||
)
|
||||
async def calibration_session_position(
|
||||
body: CalibrationSessionPositionRequest,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager), # noqa: ARG001
|
||||
) -> CalibrationSessionStateResponse:
|
||||
"""Advance the chase pixel to a specific LED index on the active device.
|
||||
|
||||
``index`` must be 0-based and < ``led_count``. Returns 422 when out of
|
||||
range (Pydantic ``ge=0``) or 400 if the session is not active / index
|
||||
exceeds led_count.
|
||||
"""
|
||||
session = get_calibration_session()
|
||||
try:
|
||||
await session.position(body.index, body.window)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except Exception as exc:
|
||||
logger.error("Failed to set calibration pixel index=%d: %s", body.index, exc, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
return CalibrationSessionStateResponse(**session.get_state())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/calibration/session/stop",
|
||||
response_model=CalibrationSessionStateResponse,
|
||||
tags=["Calibration"],
|
||||
)
|
||||
async def stop_calibration_session(
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager), # noqa: ARG001
|
||||
) -> CalibrationSessionStateResponse:
|
||||
"""End the calibration session.
|
||||
|
||||
Clears the device to black and restores the previously-running target (if
|
||||
any). Safe to call even when no session is active (returns inactive state).
|
||||
"""
|
||||
session = get_calibration_session()
|
||||
try:
|
||||
await session.stop()
|
||||
except Exception as exc:
|
||||
logger.error("Failed to stop calibration session: %s", exc, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||
|
||||
rec = get_module_recorder()
|
||||
if rec is not None:
|
||||
rec.record(
|
||||
category=ActivityCategory.SYSTEM,
|
||||
action="calibration.stopped",
|
||||
severity=ActivitySeverity.INFO,
|
||||
message="Calibration session stopped",
|
||||
)
|
||||
|
||||
return CalibrationSessionStateResponse(**session.get_state())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/calibration/session/cancel",
|
||||
response_model=CalibrationSessionStateResponse,
|
||||
tags=["Calibration"],
|
||||
)
|
||||
async def cancel_calibration_session(
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager), # noqa: ARG001
|
||||
) -> CalibrationSessionStateResponse:
|
||||
"""Cancel the calibration session (alias for stop — no calibration is applied)."""
|
||||
session = get_calibration_session()
|
||||
try:
|
||||
await session.cancel()
|
||||
except Exception as exc:
|
||||
logger.error("Failed to cancel calibration session: %s", exc, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||
|
||||
rec = get_module_recorder()
|
||||
if rec is not None:
|
||||
rec.record(
|
||||
category=ActivityCategory.SYSTEM,
|
||||
action="calibration.cancelled",
|
||||
severity=ActivitySeverity.INFO,
|
||||
message="Calibration session cancelled",
|
||||
)
|
||||
|
||||
return CalibrationSessionStateResponse(**session.get_state())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/calibration/session/state",
|
||||
response_model=CalibrationSessionStateResponse,
|
||||
tags=["Calibration"],
|
||||
)
|
||||
async def get_calibration_session_state(
|
||||
_auth: AuthRequired,
|
||||
) -> CalibrationSessionStateResponse:
|
||||
"""Return the current calibration session state."""
|
||||
return CalibrationSessionStateResponse(**get_calibration_session().get_state())
|
||||
|
||||
|
||||
# ── Solver endpoint ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/calibration/solve",
|
||||
response_model=CalibrationSolvedResponse,
|
||||
tags=["Calibration"],
|
||||
)
|
||||
async def solve_calibration_endpoint(
|
||||
body: CalibrationSolveRequest,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
) -> CalibrationSolvedResponse:
|
||||
"""Solve a CalibrationConfig from 4 corner tap indices.
|
||||
|
||||
Returns the computed per-edge LED counts. Does NOT persist — call
|
||||
``PUT /api/v1/color-strip-sources/{id}`` with ``calibration`` in the body
|
||||
to save.
|
||||
|
||||
Provide either *device_id* (preferred, server derives led_count) or
|
||||
*led_count* directly. Returns 404 if *device_id* is not found, 422 on
|
||||
invalid enum values, 400 on logical errors (e.g. corner_indices length).
|
||||
"""
|
||||
# Resolve led_count
|
||||
led_count = body.led_count
|
||||
if body.device_id is not None:
|
||||
if body.device_id not in manager._devices:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Device {body.device_id!r} not found",
|
||||
)
|
||||
ds = manager._devices[body.device_id]
|
||||
led_count = ds.led_count
|
||||
|
||||
if led_count is None or led_count <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="led_count must be a positive integer",
|
||||
)
|
||||
|
||||
try:
|
||||
cfg = solve_calibration(
|
||||
led_count=led_count,
|
||||
start_position=body.start_position,
|
||||
layout=body.layout,
|
||||
corner_indices=body.corner_indices,
|
||||
offset=body.offset,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except Exception as exc:
|
||||
logger.error("Failed to solve calibration: %s", exc, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
return CalibrationSolvedResponse(
|
||||
mode="simple",
|
||||
layout=cfg.layout,
|
||||
start_position=cfg.start_position,
|
||||
leds_top=cfg.leds_top,
|
||||
leds_right=cfg.leds_right,
|
||||
leds_bottom=cfg.leds_bottom,
|
||||
leds_left=cfg.leds_left,
|
||||
offset=cfg.offset,
|
||||
)
|
||||
@@ -43,6 +43,8 @@ def _cspt_to_response(t) -> ColorStripProcessingTemplateResponse:
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=t.tags,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -84,6 +86,8 @@ async def create_cspt(
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("cspt", "created", template.id)
|
||||
return _cspt_to_response(template)
|
||||
@@ -141,6 +145,8 @@ async def update_cspt(
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("cspt", "updated", template_id)
|
||||
return _cspt_to_response(template)
|
||||
|
||||
@@ -4,12 +4,12 @@ from ledgrab.api.schemas.color_strip_sources import (
|
||||
ApiInputCSSResponse,
|
||||
AudioCSSResponse,
|
||||
CandlelightCSSResponse,
|
||||
ColorCycleCSSResponse,
|
||||
ColorStop as ColorStopSchema,
|
||||
ColorStripSourceResponse,
|
||||
CompositeCSSResponse,
|
||||
DaylightCSSResponse,
|
||||
EffectCSSResponse,
|
||||
GameEventCSSResponse,
|
||||
GradientCSSResponse,
|
||||
KeyColorsCSSResponse,
|
||||
MappedCSSResponse,
|
||||
@@ -18,7 +18,7 @@ from ledgrab.api.schemas.color_strip_sources import (
|
||||
PictureAdvancedCSSResponse,
|
||||
PictureCSSResponse,
|
||||
ProcessedCSSResponse,
|
||||
StaticCSSResponse,
|
||||
SingleColorCSSResponse,
|
||||
WeatherCSSResponse,
|
||||
)
|
||||
from ledgrab.api.schemas.devices import Calibration as CalibrationSchema
|
||||
@@ -27,23 +27,7 @@ from ledgrab.core.capture.calibration import (
|
||||
calibration_to_dict,
|
||||
)
|
||||
from ledgrab.storage.color_strip_source import (
|
||||
AdvancedPictureColorStripSource,
|
||||
ApiInputColorStripSource,
|
||||
AudioColorStripSource,
|
||||
CandlelightColorStripSource,
|
||||
ColorCycleColorStripSource,
|
||||
CompositeColorStripSource,
|
||||
DaylightColorStripSource,
|
||||
EffectColorStripSource,
|
||||
GradientColorStripSource,
|
||||
KeyColorsColorStripSource,
|
||||
MappedColorStripSource,
|
||||
MathWaveColorStripSource,
|
||||
NotificationColorStripSource,
|
||||
PictureColorStripSource,
|
||||
ProcessedColorStripSource,
|
||||
StaticColorStripSource,
|
||||
WeatherColorStripSource,
|
||||
_SOURCE_TYPE_MAP as _STORAGE_TYPE_MAP,
|
||||
)
|
||||
from ledgrab.storage.picture_source import (
|
||||
ProcessedPictureSource,
|
||||
@@ -67,6 +51,8 @@ def _common_response_kwargs(source, overlay_active: bool = False) -> dict:
|
||||
tags=source.tags,
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
icon=getattr(source, "icon", "") or "",
|
||||
icon_color=getattr(source, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -94,38 +80,46 @@ def _stops_schema(source) -> list[ColorStopSchema] | None:
|
||||
return None
|
||||
|
||||
|
||||
# Maps storage class → response builder lambda.
|
||||
# Maps ``source_type`` string → response builder.
|
||||
#
|
||||
# Keying by source_type (rather than type(source)) lets the import-time
|
||||
# coverage check use the storage registry's keys directly, with no
|
||||
# inversion or duplicate-class handling for legacy aliases.
|
||||
_RESPONSE_MAP: dict = {
|
||||
PictureColorStripSource: lambda s, kw: PictureCSSResponse(
|
||||
"picture": lambda s, kw: PictureCSSResponse(
|
||||
**kw,
|
||||
picture_source_id=s.picture_source_id,
|
||||
smoothing=s.smoothing.to_dict(),
|
||||
interpolation_mode=s.interpolation_mode,
|
||||
calibration=_calibration_schema(s),
|
||||
),
|
||||
AdvancedPictureColorStripSource: lambda s, kw: PictureAdvancedCSSResponse(
|
||||
"picture_advanced": lambda s, kw: PictureAdvancedCSSResponse(
|
||||
**kw,
|
||||
smoothing=s.smoothing.to_dict(),
|
||||
interpolation_mode=s.interpolation_mode,
|
||||
calibration=_calibration_schema(s),
|
||||
),
|
||||
StaticColorStripSource: lambda s, kw: StaticCSSResponse(
|
||||
"single_color": lambda s, kw: SingleColorCSSResponse(
|
||||
**kw,
|
||||
color=s.color.to_dict(),
|
||||
animation=s.animation,
|
||||
),
|
||||
GradientColorStripSource: lambda s, kw: GradientCSSResponse(
|
||||
# Legacy alias: pre-rename rows used "static"; the data migration rewrites
|
||||
# them on first store load but a stale in-flight instance would still
|
||||
# carry source_type='static' until the next reload.
|
||||
"static": lambda s, kw: SingleColorCSSResponse(
|
||||
**kw,
|
||||
color=s.color.to_dict(),
|
||||
animation=s.animation,
|
||||
),
|
||||
"gradient": lambda s, kw: GradientCSSResponse(
|
||||
**kw,
|
||||
stops=_stops_schema(s),
|
||||
animation=s.animation,
|
||||
easing=s.easing,
|
||||
gradient_id=s.gradient_id,
|
||||
),
|
||||
ColorCycleColorStripSource: lambda s, kw: ColorCycleCSSResponse(
|
||||
**kw,
|
||||
colors=[list(c) for c in s.colors],
|
||||
),
|
||||
EffectColorStripSource: lambda s, kw: EffectCSSResponse(
|
||||
"effect": lambda s, kw: EffectCSSResponse(
|
||||
**kw,
|
||||
effect_type=s.effect_type,
|
||||
palette=s.palette,
|
||||
@@ -136,15 +130,15 @@ _RESPONSE_MAP: dict = {
|
||||
mirror=s.mirror,
|
||||
custom_palette=s.custom_palette,
|
||||
),
|
||||
CompositeColorStripSource: lambda s, kw: CompositeCSSResponse(
|
||||
"composite": lambda s, kw: CompositeCSSResponse(
|
||||
**kw,
|
||||
layers=[dict(layer) for layer in s.layers],
|
||||
),
|
||||
MappedColorStripSource: lambda s, kw: MappedCSSResponse(
|
||||
"mapped": lambda s, kw: MappedCSSResponse(
|
||||
**kw,
|
||||
zones=[dict(z) for z in s.zones],
|
||||
),
|
||||
AudioColorStripSource: lambda s, kw: AudioCSSResponse(
|
||||
"audio": lambda s, kw: AudioCSSResponse(
|
||||
**kw,
|
||||
visualization_mode=s.visualization_mode,
|
||||
audio_source_id=s.audio_source_id,
|
||||
@@ -157,13 +151,13 @@ _RESPONSE_MAP: dict = {
|
||||
mirror=s.mirror,
|
||||
beat_decay=s.beat_decay.to_dict(),
|
||||
),
|
||||
ApiInputColorStripSource: lambda s, kw: ApiInputCSSResponse(
|
||||
"api_input": lambda s, kw: ApiInputCSSResponse(
|
||||
**kw,
|
||||
fallback_color=s.fallback_color.to_dict(),
|
||||
timeout=s.timeout.to_dict(),
|
||||
interpolation=s.interpolation,
|
||||
),
|
||||
NotificationColorStripSource: lambda s, kw: NotificationCSSResponse(
|
||||
"notification": lambda s, kw: NotificationCSSResponse(
|
||||
**kw,
|
||||
notification_effect=s.notification_effect,
|
||||
duration_ms=s.duration_ms.to_dict(),
|
||||
@@ -176,14 +170,14 @@ _RESPONSE_MAP: dict = {
|
||||
sound_volume=s.sound_volume.to_dict(),
|
||||
app_sounds=dict(s.app_sounds),
|
||||
),
|
||||
DaylightColorStripSource: lambda s, kw: DaylightCSSResponse(
|
||||
"daylight": lambda s, kw: DaylightCSSResponse(
|
||||
**kw,
|
||||
speed=s.speed.to_dict(),
|
||||
use_real_time=s.use_real_time,
|
||||
latitude=s.latitude,
|
||||
longitude=s.longitude,
|
||||
),
|
||||
CandlelightColorStripSource: lambda s, kw: CandlelightCSSResponse(
|
||||
"candlelight": lambda s, kw: CandlelightCSSResponse(
|
||||
**kw,
|
||||
color=s.color.to_dict(),
|
||||
intensity=s.intensity.to_dict(),
|
||||
@@ -192,18 +186,18 @@ _RESPONSE_MAP: dict = {
|
||||
wind_strength=s.wind_strength.to_dict(),
|
||||
candle_type=s.candle_type,
|
||||
),
|
||||
ProcessedColorStripSource: lambda s, kw: ProcessedCSSResponse(
|
||||
"processed": lambda s, kw: ProcessedCSSResponse(
|
||||
**kw,
|
||||
input_source_id=s.input_source_id,
|
||||
processing_template_id=s.processing_template_id,
|
||||
),
|
||||
WeatherColorStripSource: lambda s, kw: WeatherCSSResponse(
|
||||
"weather": lambda s, kw: WeatherCSSResponse(
|
||||
**kw,
|
||||
weather_source_id=s.weather_source_id,
|
||||
speed=s.speed.to_dict(),
|
||||
temperature_influence=s.temperature_influence.to_dict(),
|
||||
),
|
||||
KeyColorsColorStripSource: lambda s, kw: KeyColorsCSSResponse(
|
||||
"key_colors": lambda s, kw: KeyColorsCSSResponse(
|
||||
**kw,
|
||||
picture_source_id=s.picture_source_id,
|
||||
rectangles=[r.to_dict() for r in s.rectangles],
|
||||
@@ -211,28 +205,67 @@ _RESPONSE_MAP: dict = {
|
||||
smoothing=s.smoothing.to_dict(),
|
||||
brightness=s.brightness.to_dict(),
|
||||
),
|
||||
MathWaveColorStripSource: lambda s, kw: MathWaveCSSResponse(
|
||||
"math_wave": lambda s, kw: MathWaveCSSResponse(
|
||||
**kw,
|
||||
waves=s.waves,
|
||||
speed=s.speed.to_dict(),
|
||||
gradient_id=s.gradient_id,
|
||||
),
|
||||
"game_event": lambda s, kw: GameEventCSSResponse(
|
||||
**kw,
|
||||
game_integration_id=s.game_integration_id,
|
||||
idle_color=s.idle_color.to_dict(),
|
||||
event_mappings=[dict(m) for m in s.event_mappings],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _assert_response_map_coverage() -> None:
|
||||
"""Verify _RESPONSE_MAP has a builder for every kind in storage's registry.
|
||||
|
||||
Runs at module import. Surfaces missing builders eagerly instead of
|
||||
letting a request fall through to a silent / wrong response shape.
|
||||
|
||||
Contract note
|
||||
-------------
|
||||
This check is **symmetric** (``_RESPONSE_MAP keys == storage_kinds``)
|
||||
because every kind — sharable or not — needs a response shape. The
|
||||
sister assertion in
|
||||
``core/processing/color_strip_kinds.py::_assert_stream_kind_coverage``
|
||||
is asymmetric because sharable kinds construct their streams via a
|
||||
different path. Adding a new kind requires keeping all three registries
|
||||
aligned: storage's ``_SOURCE_TYPE_MAP``, this ``_RESPONSE_MAP``, and
|
||||
either ``STREAM_BUILDERS`` or ``SHARABLE_KINDS``.
|
||||
"""
|
||||
storage_kinds = set(_STORAGE_TYPE_MAP.keys())
|
||||
builder_kinds = set(_RESPONSE_MAP.keys())
|
||||
missing = storage_kinds - builder_kinds
|
||||
extra = builder_kinds - storage_kinds
|
||||
if missing or extra:
|
||||
problems = []
|
||||
if missing:
|
||||
problems.append(f"missing builders for: {sorted(missing)}")
|
||||
if extra:
|
||||
problems.append(f"unregistered kinds in _RESPONSE_MAP: {sorted(extra)}")
|
||||
raise RuntimeError(
|
||||
"_RESPONSE_MAP is out of sync with storage._SOURCE_TYPE_MAP: " + "; ".join(problems)
|
||||
)
|
||||
|
||||
|
||||
_assert_response_map_coverage()
|
||||
|
||||
|
||||
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
|
||||
"""Convert a ColorStripSource to the matching per-type response schema."""
|
||||
kw = _common_response_kwargs(source, overlay_active)
|
||||
builder = _RESPONSE_MAP.get(type(source))
|
||||
builder = _RESPONSE_MAP.get(source.source_type)
|
||||
if builder is None:
|
||||
# Fallback: use to_dict() and build a PictureCSSResponse
|
||||
logger.warning("No response builder for %s, falling back", type(source).__name__)
|
||||
return PictureCSSResponse(
|
||||
**kw,
|
||||
picture_source_id="",
|
||||
smoothing=0.3,
|
||||
interpolation_mode="average",
|
||||
calibration=None,
|
||||
# Coverage is asserted at import time, so reaching this branch means a
|
||||
# source was loaded with a source_type that is not registered.
|
||||
# Surface the bug instead of silently returning a wrong-shaped response.
|
||||
raise RuntimeError(
|
||||
f"No CSS response builder registered for source_type "
|
||||
f"{source.source_type!r} (class={type(source).__name__})"
|
||||
)
|
||||
return builder(source, kw)
|
||||
|
||||
|
||||
@@ -167,6 +167,12 @@ async def delete_color_strip_source(
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
):
|
||||
"""Delete a color strip source. Returns 409 if referenced by any LED target."""
|
||||
_entity_name: str | None = None
|
||||
try:
|
||||
_entity_name = store.get_source(source_id).name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
target_names = target_store.get_targets_referencing_css(source_id)
|
||||
if target_names:
|
||||
@@ -201,7 +207,7 @@ async def delete_color_strip_source(
|
||||
"Delete or reassign the processed source(s) first.",
|
||||
)
|
||||
store.delete_source(source_id)
|
||||
fire_entity_event("color_strip_source", "deleted", source_id)
|
||||
fire_entity_event("color_strip_source", "deleted", source_id, entity_name=_entity_name)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
|
||||
@@ -29,13 +29,20 @@ router = APIRouter()
|
||||
|
||||
|
||||
_PREVIEW_ALLOWED_TYPES = {
|
||||
"static",
|
||||
"single_color",
|
||||
"gradient",
|
||||
"color_cycle",
|
||||
"effect",
|
||||
"daylight",
|
||||
"candlelight",
|
||||
"notification",
|
||||
"audio",
|
||||
"math_wave",
|
||||
"weather",
|
||||
"game_event",
|
||||
"api_input",
|
||||
"mapped",
|
||||
"composite",
|
||||
"processed",
|
||||
}
|
||||
|
||||
|
||||
@@ -90,13 +97,65 @@ async def preview_color_strip_ws(
|
||||
return ColorStripSource.from_dict(config)
|
||||
|
||||
def _create_stream(source):
|
||||
"""Instantiate and start the appropriate stream class for *source*."""
|
||||
from ledgrab.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP
|
||||
"""Instantiate and start the appropriate stream class for *source*.
|
||||
|
||||
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
|
||||
if not stream_cls:
|
||||
raise ValueError(f"Unsupported preview source_type: {source.source_type}")
|
||||
s = stream_cls(source)
|
||||
Delegates the per-kind dispatch to ``color_strip_kinds.build_stream``
|
||||
so this preview path and the production ``ColorStripStreamManager``
|
||||
share a single registry. Per-kind dependencies (CSPT store, audio
|
||||
stores, weather manager, …) are gathered into a ``StreamDeps`` bag.
|
||||
|
||||
FastAPI-DI providers raise ``RuntimeError`` when they aren't wired,
|
||||
so we resolve each one through ``_safe`` and pass ``None`` on
|
||||
failure. The per-kind builder will still see a clear error if a
|
||||
truly-required dep is missing for that kind, but unrelated previews
|
||||
(e.g. a ``single_color`` preview on a fresh install where the CSPT
|
||||
store isn't initialized yet) keep working.
|
||||
"""
|
||||
from ledgrab.api.dependencies import (
|
||||
get_audio_processing_template_store,
|
||||
get_audio_source_store,
|
||||
get_audio_template_store,
|
||||
get_cspt_store,
|
||||
)
|
||||
from ledgrab.core.processing.color_strip_kinds import StreamDeps, build_stream
|
||||
|
||||
def _safe(getter):
|
||||
try:
|
||||
return getter()
|
||||
except RuntimeError as e:
|
||||
logger.debug("Preview dep not available (%s): %s", getter.__name__, e)
|
||||
return None
|
||||
|
||||
mgr = get_processor_manager()
|
||||
csm = mgr.color_strip_stream_manager
|
||||
|
||||
# The game-event bus is optional in preview contexts.
|
||||
try:
|
||||
from ledgrab.api.dependencies import get_game_event_bus
|
||||
|
||||
game_event_bus = get_game_event_bus()
|
||||
except RuntimeError as e:
|
||||
logger.debug("Preview: no game event bus available: %s", e)
|
||||
game_event_bus = None
|
||||
|
||||
deps = StreamDeps(
|
||||
css_manager=csm,
|
||||
value_stream_manager=mgr.value_stream_manager,
|
||||
cspt_store=_safe(get_cspt_store),
|
||||
weather_manager=mgr.weather_manager,
|
||||
audio_capture_manager=mgr.audio_capture_manager,
|
||||
audio_source_store=_safe(get_audio_source_store),
|
||||
audio_template_store=_safe(get_audio_template_store),
|
||||
audio_processing_template_store=_safe(get_audio_processing_template_store),
|
||||
game_event_bus=game_event_bus,
|
||||
depth=0,
|
||||
)
|
||||
try:
|
||||
s = build_stream(source, deps)
|
||||
except ValueError as e:
|
||||
# Preserve the registry's original detail so the API consumer
|
||||
# sees which kind was rejected, not just a generic message.
|
||||
raise ValueError(f"Unsupported preview source_type: {e}") from e
|
||||
# Inject gradient store for palette resolution
|
||||
if hasattr(s, "set_gradient_store"):
|
||||
try:
|
||||
@@ -122,7 +181,24 @@ async def preview_color_strip_ws(
|
||||
cid = None
|
||||
else:
|
||||
cid = None
|
||||
# Start the stream; if start() raises, release any resources we
|
||||
# already acquired (clock + anything the stream itself grabbed in
|
||||
# its __init__) so we don't leak refs across failed previews.
|
||||
try:
|
||||
s.start()
|
||||
except Exception:
|
||||
try:
|
||||
s.stop()
|
||||
except Exception as e_stop:
|
||||
logger.exception("unexpected in start-failure rollback s.stop: %s", e_stop)
|
||||
if cid:
|
||||
scm = _get_sync_clock_manager()
|
||||
if scm:
|
||||
try:
|
||||
scm.release(cid)
|
||||
except Exception as e_rel:
|
||||
logger.exception("unexpected in start-failure clock release: %s", e_rel)
|
||||
raise
|
||||
return s, cid
|
||||
|
||||
def _stop_stream(s, cid):
|
||||
@@ -223,10 +299,24 @@ async def preview_color_strip_ws(
|
||||
continue
|
||||
new_source = _build_source(new_config)
|
||||
if new_type != current_source_type:
|
||||
# Source type changed — recreate stream
|
||||
# Source type changed — stop the old stream first, then
|
||||
# build the new one. If the rebuild fails, drop the
|
||||
# reference so the frame loop doesn't keep polling a
|
||||
# stopped stream and the finally-block doesn't double-stop.
|
||||
_stop_stream(stream, clock_id)
|
||||
stream, clock_id = None, None
|
||||
try:
|
||||
stream, clock_id = _create_stream(new_source)
|
||||
current_source_type = new_type
|
||||
except Exception as rebuild_err:
|
||||
logger.error(
|
||||
f"Preview WS: failed to rebuild stream for new type {new_type}: {rebuild_err}"
|
||||
)
|
||||
await websocket.send_text(
|
||||
_json.dumps({"type": "error", "detail": str(rebuild_err)})
|
||||
)
|
||||
await websocket.close(code=4003, reason=str(rebuild_err))
|
||||
return
|
||||
else:
|
||||
stream.update_source(new_source)
|
||||
if hasattr(stream, "configure"):
|
||||
@@ -237,6 +327,9 @@ async def preview_color_strip_ws(
|
||||
await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)}))
|
||||
|
||||
# Send frame
|
||||
if stream is None:
|
||||
await websocket.send_bytes(b"\x00" * led_count * 3)
|
||||
else:
|
||||
colors = stream.get_latest_colors()
|
||||
if colors is not None:
|
||||
await websocket.send_bytes(colors.tobytes())
|
||||
@@ -335,8 +428,17 @@ async def css_api_input_ws(
|
||||
continue
|
||||
|
||||
elif "bytes" in message:
|
||||
# Binary frame: raw RGBRGB... bytes (3 bytes per LED)
|
||||
# Binary frame: raw RGBRGB... bytes (3 bytes per LED).
|
||||
# Cap to a generous upper bound on the LED count — a hostile
|
||||
# client could otherwise stream 100 MB frames and OOM the
|
||||
# server before any application logic ran.
|
||||
raw_bytes = message["bytes"]
|
||||
_MAX_BINARY_LEDS = 8192
|
||||
if len(raw_bytes) > _MAX_BINARY_LEDS * 3:
|
||||
await websocket.send_json(
|
||||
{"error": f"Binary frame too large (max {_MAX_BINARY_LEDS} LEDs)"}
|
||||
)
|
||||
continue
|
||||
if len(raw_bytes) % 3 != 0:
|
||||
await websocket.send_json({"error": "Binary data must be multiple of 3 bytes"})
|
||||
continue
|
||||
@@ -476,10 +578,13 @@ async def test_color_strip_ws(
|
||||
meta["layer_infos"] = layer_infos
|
||||
await websocket.send_text(_json.dumps(meta))
|
||||
|
||||
# For api_input: send the current buffer immediately so the client
|
||||
# gets a frame right away (fallback color if inactive) rather than
|
||||
# leaving the canvas blank/stale until external data arrives.
|
||||
# For api_input: only send an initial frame if a client has actually
|
||||
# pushed data (push_generation > 0). Without prior data, the preview
|
||||
# stays blank instead of showing the fallback buffer as a stray frame.
|
||||
if is_api_input:
|
||||
initial_gen = stream.push_generation
|
||||
if initial_gen > 0:
|
||||
_last_push_gen = initial_gen
|
||||
initial_colors = stream.get_latest_colors()
|
||||
if initial_colors is not None:
|
||||
await websocket.send_bytes(initial_colors.tobytes())
|
||||
|
||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock
|
||||
|
||||
from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.core.devices.led_client import (
|
||||
PairingNotReady,
|
||||
get_all_providers,
|
||||
get_device_capabilities,
|
||||
get_provider,
|
||||
@@ -12,6 +13,7 @@ from ledgrab.core.devices.led_client import (
|
||||
from ledgrab.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_device_store,
|
||||
get_mqtt_store,
|
||||
get_output_target_store,
|
||||
get_processor_manager,
|
||||
)
|
||||
@@ -26,18 +28,48 @@ from ledgrab.api.schemas.devices import (
|
||||
DiscoverDevicesResponse,
|
||||
OpenRGBZoneResponse,
|
||||
OpenRGBZonesResponse,
|
||||
PairDeviceRequest,
|
||||
PairDeviceResponse,
|
||||
PowerRequest,
|
||||
)
|
||||
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||
from ledgrab.storage import DeviceStore
|
||||
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
|
||||
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.url_scheme import infer_http_scheme
|
||||
|
||||
from ._mqtt_validation import validate_mqtt_source_exists
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _sanitize_url_for_log(url: str) -> str:
|
||||
"""Strip userinfo + fragment from a URL so secrets don't reach logs.
|
||||
|
||||
The pair endpoint receives a user-supplied URL on every call; if a
|
||||
future driver ever accepts ``scheme://user:pass@host`` form the
|
||||
credentials would land in logs without this guard.
|
||||
"""
|
||||
if not url:
|
||||
return ""
|
||||
try:
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
# urlparse stores userinfo in `netloc`; rebuild without it.
|
||||
if parsed.hostname:
|
||||
netloc = parsed.hostname
|
||||
if parsed.port:
|
||||
netloc = f"{netloc}:{parsed.port}"
|
||||
return urlunparse((parsed.scheme, netloc, parsed.path, parsed.params, parsed.query, ""))
|
||||
except ValueError:
|
||||
pass
|
||||
return url
|
||||
|
||||
|
||||
def _device_to_response(device) -> DeviceResponse:
|
||||
"""Convert a Device to DeviceResponse."""
|
||||
return DeviceResponse(
|
||||
@@ -57,20 +89,35 @@ def _device_to_response(device) -> DeviceResponse:
|
||||
dmx_protocol=device.dmx_protocol,
|
||||
dmx_start_universe=device.dmx_start_universe,
|
||||
dmx_start_channel=device.dmx_start_channel,
|
||||
ddp_port=device.ddp_port,
|
||||
ddp_destination_id=device.ddp_destination_id,
|
||||
ddp_color_order=device.ddp_color_order,
|
||||
espnow_peer_mac=device.espnow_peer_mac,
|
||||
espnow_channel=device.espnow_channel,
|
||||
hue_username=device.hue_username,
|
||||
hue_client_key=device.hue_client_key,
|
||||
hue_paired=bool(device.hue_username and device.hue_client_key),
|
||||
hue_entertainment_group_id=device.hue_entertainment_group_id,
|
||||
hue_gradient_mode=device.hue_gradient_mode,
|
||||
yeelight_min_interval_ms=device.yeelight_min_interval_ms,
|
||||
wiz_min_interval_ms=device.wiz_min_interval_ms,
|
||||
lifx_min_interval_ms=device.lifx_min_interval_ms,
|
||||
lifx_per_zone=device.lifx_per_zone,
|
||||
govee_min_interval_ms=device.govee_min_interval_ms,
|
||||
opc_channel=device.opc_channel,
|
||||
nanoleaf_paired=bool(device.nanoleaf_token),
|
||||
nanoleaf_min_interval_ms=device.nanoleaf_min_interval_ms,
|
||||
nanoleaf_per_panel=device.nanoleaf_per_panel,
|
||||
spi_speed_hz=device.spi_speed_hz,
|
||||
spi_led_type=device.spi_led_type,
|
||||
chroma_device_type=device.chroma_device_type,
|
||||
gamesense_device_type=device.gamesense_device_type,
|
||||
ble_family=device.ble_family,
|
||||
ble_govee_key=device.ble_govee_key,
|
||||
mqtt_source_id=getattr(device, "mqtt_source_id", "") or "",
|
||||
default_css_processing_template_id=device.default_css_processing_template_id,
|
||||
group_device_ids=device.group_device_ids,
|
||||
group_mode=device.group_mode,
|
||||
icon=getattr(device, "icon", "") or "",
|
||||
icon_color=getattr(device, "icon_color", "") or "",
|
||||
created_at=device.created_at,
|
||||
updated_at=device.updated_at,
|
||||
)
|
||||
@@ -85,11 +132,13 @@ async def create_device(
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
|
||||
):
|
||||
"""Create and attach a new LED device."""
|
||||
try:
|
||||
device_type = device_data.device_type
|
||||
logger.info(f"Creating {device_type} device: {device_data.name}")
|
||||
validate_mqtt_source_exists(mqtt_store, device_data.mqtt_source_id)
|
||||
|
||||
# ── Group device: validate children + compute LED count ──
|
||||
if device_type == "group":
|
||||
@@ -132,6 +181,8 @@ async def create_device(
|
||||
detail="URL is required for non-group device types.",
|
||||
)
|
||||
device_url = device_data.url.rstrip("/")
|
||||
if device_type == "wled":
|
||||
device_url = infer_http_scheme(device_url)
|
||||
|
||||
# ── Non-group: validate via provider ──
|
||||
if device_type != "group":
|
||||
@@ -166,9 +217,19 @@ async def create_device(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
# Don't leak the raw exception text — it can carry stack
|
||||
# frames, host headers, or other internals that aren't safe
|
||||
# to echo. Log with full context, return a generic message.
|
||||
logger.warning(
|
||||
"Failed to validate %s device at %s: %s",
|
||||
device_type,
|
||||
device_url,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Failed to connect to {device_type} device at {device_url}: {e}",
|
||||
detail=f"Failed to connect to {device_type} device at {device_url}.",
|
||||
)
|
||||
|
||||
# Resolve auto_shutdown default: False for all types
|
||||
@@ -179,7 +240,7 @@ async def create_device(
|
||||
# Create device in storage
|
||||
device = store.create_device(
|
||||
name=device_data.name,
|
||||
url=device_data.url,
|
||||
url=device_url,
|
||||
led_count=led_count,
|
||||
device_type=device_type,
|
||||
baud_rate=device_data.baud_rate,
|
||||
@@ -191,17 +252,57 @@ async def create_device(
|
||||
dmx_protocol=device_data.dmx_protocol or "artnet",
|
||||
dmx_start_universe=device_data.dmx_start_universe or 0,
|
||||
dmx_start_channel=device_data.dmx_start_channel or 1,
|
||||
ddp_port=device_data.ddp_port or 0,
|
||||
ddp_destination_id=(
|
||||
device_data.ddp_destination_id if device_data.ddp_destination_id is not None else 1
|
||||
),
|
||||
ddp_color_order=(
|
||||
device_data.ddp_color_order if device_data.ddp_color_order is not None else 1
|
||||
),
|
||||
espnow_peer_mac=device_data.espnow_peer_mac or "",
|
||||
espnow_channel=device_data.espnow_channel or 1,
|
||||
hue_username=device_data.hue_username or "",
|
||||
hue_client_key=device_data.hue_client_key or "",
|
||||
hue_entertainment_group_id=device_data.hue_entertainment_group_id or "",
|
||||
hue_gradient_mode=(
|
||||
device_data.hue_gradient_mode if device_data.hue_gradient_mode is not None else True
|
||||
),
|
||||
yeelight_min_interval_ms=(
|
||||
device_data.yeelight_min_interval_ms
|
||||
if device_data.yeelight_min_interval_ms is not None
|
||||
else 500
|
||||
),
|
||||
wiz_min_interval_ms=(
|
||||
device_data.wiz_min_interval_ms
|
||||
if device_data.wiz_min_interval_ms is not None
|
||||
else 50
|
||||
),
|
||||
lifx_min_interval_ms=(
|
||||
device_data.lifx_min_interval_ms
|
||||
if device_data.lifx_min_interval_ms is not None
|
||||
else 50
|
||||
),
|
||||
lifx_per_zone=bool(device_data.lifx_per_zone),
|
||||
govee_min_interval_ms=(
|
||||
device_data.govee_min_interval_ms
|
||||
if device_data.govee_min_interval_ms is not None
|
||||
else 50
|
||||
),
|
||||
opc_channel=(device_data.opc_channel if device_data.opc_channel is not None else 0),
|
||||
nanoleaf_token=device_data.nanoleaf_token or "",
|
||||
nanoleaf_min_interval_ms=(
|
||||
device_data.nanoleaf_min_interval_ms
|
||||
if device_data.nanoleaf_min_interval_ms is not None
|
||||
else 100
|
||||
),
|
||||
nanoleaf_per_panel=bool(device_data.nanoleaf_per_panel),
|
||||
spi_speed_hz=device_data.spi_speed_hz or 800000,
|
||||
spi_led_type=device_data.spi_led_type or "WS2812B",
|
||||
chroma_device_type=device_data.chroma_device_type or "chromalink",
|
||||
gamesense_device_type=device_data.gamesense_device_type or "keyboard",
|
||||
ble_family=device_data.ble_family or "",
|
||||
ble_govee_key=device_data.ble_govee_key or "",
|
||||
mqtt_source_id=device_data.mqtt_source_id or "",
|
||||
group_device_ids=group_device_ids,
|
||||
group_mode=group_mode,
|
||||
)
|
||||
@@ -231,6 +332,79 @@ async def create_device(
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/devices/pair",
|
||||
response_model=PairDeviceResponse,
|
||||
tags=["Devices"],
|
||||
)
|
||||
async def pair_device(
|
||||
body: PairDeviceRequest,
|
||||
_auth: AuthRequired,
|
||||
):
|
||||
"""Run a pairing handshake against a device before creating it.
|
||||
|
||||
The frontend opens this endpoint after the user has performed the
|
||||
device's physical pairing action (e.g. held the power button for 5s).
|
||||
The response carries provider-specific fields the caller must include
|
||||
in the subsequent ``POST /api/v1/devices`` body.
|
||||
|
||||
Status codes:
|
||||
200 paired — fields returned
|
||||
400 unknown device type, or device type does not support pairing
|
||||
409 device not ready — user must perform the physical action
|
||||
(or retry, e.g. the pairing window timed out)
|
||||
422 invalid URL or device configuration
|
||||
"""
|
||||
try:
|
||||
provider = get_provider(body.device_type)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown device type: {body.device_type}")
|
||||
|
||||
try:
|
||||
fields = await provider.pair_device(body.url)
|
||||
except NotImplementedError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Device type {body.device_type!r} does not support pairing",
|
||||
)
|
||||
except PairingNotReady as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
except Exception as exc:
|
||||
# Strip userinfo before logging so a `scheme://user:pass@host` URL
|
||||
# never lands in the logs (no shipped driver uses userinfo today,
|
||||
# but the pattern is a foot-gun for the next driver author --
|
||||
# caught by review MEDIUM #9). Also keep exc_info=False so a
|
||||
# provider stack trace that may include response bytes from a
|
||||
# hostile receiver doesn't end up in the file either.
|
||||
safe_url = _sanitize_url_for_log(body.url)
|
||||
logger.warning(
|
||||
"Pairing failed for %s at %s: %s: %s",
|
||||
body.device_type,
|
||||
safe_url,
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Pairing failed for {body.device_type} at {safe_url}.",
|
||||
)
|
||||
|
||||
if not isinstance(fields, dict):
|
||||
logger.warning(
|
||||
"Provider %s.pair_device returned %r (expected dict)",
|
||||
body.device_type,
|
||||
type(fields).__name__,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Provider {body.device_type!r} returned malformed pairing result",
|
||||
)
|
||||
|
||||
return PairDeviceResponse(fields=fields)
|
||||
|
||||
|
||||
@router.get("/api/v1/devices", response_model=DeviceListResponse, tags=["Devices"])
|
||||
async def list_devices(
|
||||
_auth: AuthRequired,
|
||||
@@ -264,11 +438,20 @@ async def discover_devices(
|
||||
raise HTTPException(status_code=400, detail=f"Unknown device type: {device_type}")
|
||||
discovered = await provider.discover(timeout=capped_timeout)
|
||||
else:
|
||||
# Discover from all providers in parallel
|
||||
# Discover from all providers in parallel. Discovery is best-effort:
|
||||
# one provider failing (firewall, missing dep, mDNS race) must not
|
||||
# take the entire scan down, so collect exceptions instead of
|
||||
# raising and log them individually.
|
||||
providers = get_all_providers()
|
||||
discover_tasks = [p.discover(timeout=capped_timeout) for p in providers.values()]
|
||||
all_results = await asyncio.gather(*discover_tasks)
|
||||
discovered = [d for batch in all_results for d in batch]
|
||||
provider_items = list(providers.items())
|
||||
discover_tasks = [p.discover(timeout=capped_timeout) for _, p in provider_items]
|
||||
all_results = await asyncio.gather(*discover_tasks, return_exceptions=True)
|
||||
discovered = []
|
||||
for (name, _), result in zip(provider_items, all_results):
|
||||
if isinstance(result, BaseException):
|
||||
logger.warning("Discovery failed for provider %s: %s", name, result)
|
||||
continue
|
||||
discovered.extend(result)
|
||||
elapsed_ms = (time.time() - start) * 1000
|
||||
|
||||
existing_urls = {d.url.rstrip("/").lower() for d in store.get_all_devices()}
|
||||
@@ -376,12 +559,34 @@ async def update_device(
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
|
||||
):
|
||||
"""Update device information."""
|
||||
try:
|
||||
# Group-specific validation before applying update
|
||||
existing = store.get_device(device_id)
|
||||
is_group = existing.device_type == "group"
|
||||
validate_mqtt_source_exists(mqtt_store, update_data.mqtt_source_id)
|
||||
|
||||
# Normalize URL the same way we do on create:
|
||||
# * always rstrip trailing slashes (so PUT-with-trailing-/ matches
|
||||
# POST-with-trailing-/ in the stored value -- caught by review HIGH #6)
|
||||
# * only WLED gets http/https scheme inference; other schemes
|
||||
# (yeelight://, lifx://, opc://, ddp://, …) pass through.
|
||||
# Done via a local rather than mutating the request DTO so the
|
||||
# input is preserved for any future caller that inspects it.
|
||||
normalized_url = update_data.url
|
||||
if update_data.url:
|
||||
normalized_url = update_data.url.rstrip("/")
|
||||
if existing.device_type == "wled":
|
||||
inferred = infer_http_scheme(normalized_url)
|
||||
if inferred != normalized_url:
|
||||
logger.debug("Inferred WLED URL scheme: %r -> %r", normalized_url, inferred)
|
||||
normalized_url = inferred
|
||||
|
||||
# Group-only field overrides (led_count auto-recompute) are accumulated
|
||||
# here too so the update_data Pydantic model is not mutated in place.
|
||||
normalized_led_count = update_data.led_count
|
||||
|
||||
if is_group:
|
||||
new_children = update_data.group_device_ids
|
||||
@@ -403,20 +608,20 @@ async def update_device(
|
||||
|
||||
# Auto-recompute led_count for sequence mode
|
||||
if effective_mode == "sequence":
|
||||
update_data.led_count = store.resolve_group_led_count(effective_children)
|
||||
normalized_led_count = store.resolve_group_led_count(effective_children)
|
||||
elif (
|
||||
update_data.led_count is None
|
||||
normalized_led_count is None
|
||||
and new_mode == "independent"
|
||||
and new_children is not None
|
||||
):
|
||||
update_data.led_count = store.resolve_group_max_led_count(effective_children)
|
||||
normalized_led_count = store.resolve_group_max_led_count(effective_children)
|
||||
|
||||
device = store.update_device(
|
||||
device_id=device_id,
|
||||
name=update_data.name,
|
||||
url=update_data.url,
|
||||
url=normalized_url,
|
||||
enabled=update_data.enabled,
|
||||
led_count=update_data.led_count,
|
||||
led_count=normalized_led_count,
|
||||
baud_rate=update_data.baud_rate,
|
||||
auto_shutdown=update_data.auto_shutdown,
|
||||
send_latency_ms=update_data.send_latency_ms,
|
||||
@@ -426,32 +631,54 @@ async def update_device(
|
||||
dmx_protocol=update_data.dmx_protocol,
|
||||
dmx_start_universe=update_data.dmx_start_universe,
|
||||
dmx_start_channel=update_data.dmx_start_channel,
|
||||
ddp_port=update_data.ddp_port,
|
||||
ddp_destination_id=update_data.ddp_destination_id,
|
||||
ddp_color_order=update_data.ddp_color_order,
|
||||
espnow_peer_mac=update_data.espnow_peer_mac,
|
||||
espnow_channel=update_data.espnow_channel,
|
||||
hue_username=update_data.hue_username,
|
||||
hue_client_key=update_data.hue_client_key,
|
||||
hue_entertainment_group_id=update_data.hue_entertainment_group_id,
|
||||
hue_gradient_mode=update_data.hue_gradient_mode,
|
||||
yeelight_min_interval_ms=update_data.yeelight_min_interval_ms,
|
||||
wiz_min_interval_ms=update_data.wiz_min_interval_ms,
|
||||
lifx_min_interval_ms=update_data.lifx_min_interval_ms,
|
||||
lifx_per_zone=update_data.lifx_per_zone,
|
||||
govee_min_interval_ms=update_data.govee_min_interval_ms,
|
||||
opc_channel=update_data.opc_channel,
|
||||
nanoleaf_token=update_data.nanoleaf_token,
|
||||
nanoleaf_min_interval_ms=update_data.nanoleaf_min_interval_ms,
|
||||
nanoleaf_per_panel=update_data.nanoleaf_per_panel,
|
||||
spi_speed_hz=update_data.spi_speed_hz,
|
||||
spi_led_type=update_data.spi_led_type,
|
||||
chroma_device_type=update_data.chroma_device_type,
|
||||
gamesense_device_type=update_data.gamesense_device_type,
|
||||
ble_family=update_data.ble_family,
|
||||
ble_govee_key=update_data.ble_govee_key,
|
||||
mqtt_source_id=update_data.mqtt_source_id,
|
||||
group_device_ids=update_data.group_device_ids,
|
||||
group_mode=update_data.group_mode,
|
||||
icon=update_data.icon,
|
||||
icon_color=update_data.icon_color,
|
||||
)
|
||||
|
||||
# Sync connection info in processor manager
|
||||
# Sync connection info in processor manager.
|
||||
#
|
||||
# When a PATCH omits `url` (rename / icon-only edit) `normalized_url`
|
||||
# is None — fall back to the existing record's URL so the processor
|
||||
# is always told the current address, otherwise it silently keeps
|
||||
# whatever it had cached (or worse, treats None as "unconfigured"
|
||||
# and refuses to re-sync).
|
||||
effective_url = normalized_url if normalized_url is not None else existing.url
|
||||
try:
|
||||
manager.update_device_info(
|
||||
device_id,
|
||||
device_url=update_data.url,
|
||||
led_count=update_data.led_count,
|
||||
device_url=effective_url,
|
||||
led_count=normalized_led_count,
|
||||
baud_rate=update_data.baud_rate,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.debug("Processor manager device update skipped for %s: %s", device_id, e)
|
||||
pass
|
||||
|
||||
# Sync auto_shutdown and zone_mode in runtime state
|
||||
ds = manager.find_device_state(device_id)
|
||||
@@ -464,6 +691,10 @@ async def update_device(
|
||||
fire_entity_event("device", "updated", device_id)
|
||||
return _device_to_response(device)
|
||||
|
||||
except HTTPException:
|
||||
# Intentional 4xx (e.g. unknown mqtt_source_id, group validation)
|
||||
# must propagate unchanged — not be masked as a 500.
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -481,6 +712,13 @@ async def delete_device(
|
||||
):
|
||||
"""Delete/detach a device. Returns 409 if referenced by a target."""
|
||||
try:
|
||||
# Resolve name before deletion for the audit record.
|
||||
_entity_name: str | None = None
|
||||
try:
|
||||
_entity_name = store.get_device(device_id).name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check if any target references this device
|
||||
refs = target_store.get_targets_for_device(device_id)
|
||||
if refs:
|
||||
@@ -508,7 +746,7 @@ async def delete_device(
|
||||
# Delete from storage
|
||||
store.delete_device(device_id)
|
||||
|
||||
fire_entity_event("device", "deleted", device_id)
|
||||
fire_entity_event("device", "deleted", device_id, entity_name=_entity_name)
|
||||
logger.info(f"Deleted device {device_id}")
|
||||
|
||||
except HTTPException:
|
||||
@@ -572,6 +810,32 @@ async def ping_device(
|
||||
# ===== WLED BRIGHTNESS ENDPOINTS =====
|
||||
|
||||
|
||||
async def resolve_device_brightness(device, manager: ProcessorManager) -> int | None:
|
||||
"""Resolve a device's current brightness for aggregate/batch reads.
|
||||
|
||||
Mirrors GET /brightness but degrades to ``None`` instead of raising, so one
|
||||
unreachable device can't fail a whole snapshot. Reads the server-side cache
|
||||
first and only touches hardware when the cache is cold, then populates it so
|
||||
subsequent reads are I/O-free.
|
||||
"""
|
||||
if "brightness_control" not in get_device_capabilities(device.device_type):
|
||||
return None
|
||||
ds = manager.find_device_state(device.id)
|
||||
if ds and ds.hardware_brightness is not None:
|
||||
return ds.hardware_brightness
|
||||
try:
|
||||
provider = get_provider(device.device_type)
|
||||
bri = await provider.get_brightness(device.url)
|
||||
if ds:
|
||||
ds.hardware_brightness = bri
|
||||
return bri
|
||||
except NotImplementedError:
|
||||
return device.software_brightness
|
||||
except Exception as e:
|
||||
logger.warning("Failed to resolve brightness for device %s: %s", device.id, e)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
|
||||
async def get_device_brightness(
|
||||
device_id: str,
|
||||
|
||||
@@ -6,6 +6,7 @@ adapter metadata, and diagnostics.
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
@@ -16,6 +17,7 @@ from ledgrab.api.dependencies import (
|
||||
get_database,
|
||||
get_game_integration_store,
|
||||
get_game_event_bus,
|
||||
get_lol_poll_manager,
|
||||
)
|
||||
from ledgrab.api.schemas.game_integration import (
|
||||
AdapterInfoResponse,
|
||||
@@ -36,9 +38,16 @@ from ledgrab.api.schemas.game_integration import (
|
||||
)
|
||||
from ledgrab.core.game_integration.adapter_registry import AdapterRegistry
|
||||
from ledgrab.core.game_integration.event_bus import GameEventBus
|
||||
from ledgrab.core.game_integration.events import GameEvent
|
||||
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
|
||||
from ledgrab.core.game_integration.runtime_state import (
|
||||
cleanup_state as _cleanup_state,
|
||||
get_prev_state as _get_prev_state,
|
||||
get_stats as _get_stats,
|
||||
record_events as _record_events,
|
||||
set_prev_state as _set_prev_state,
|
||||
)
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
from ledgrab.storage.game_integration import EventMapping
|
||||
from ledgrab.storage.game_integration import _SECRET_CONFIG_KEYS, EventMapping
|
||||
from ledgrab.storage.game_integration_store import GameIntegrationStore
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
@@ -46,15 +55,77 @@ logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ── Per-integration runtime state (in-memory, not persisted) ──────────────
|
||||
# Per-integration runtime state (prev-state + stats + payload processing) lives
|
||||
# in ``core/game_integration/runtime_state.py`` and is imported above under the
|
||||
# legacy ``_get_prev_state`` / ``_record_events`` / … names so both this route
|
||||
# and the LoL poll manager share one set of counters.
|
||||
|
||||
_integration_state_lock = threading.Lock()
|
||||
|
||||
# integration_id -> prev_state dict for diff-based trigger detection
|
||||
_prev_states: dict[str, dict[str, Any]] = {}
|
||||
# ── Failed-auth rate limiter (brute-force defence on the ingest route) ─────
|
||||
#
|
||||
# The ingest route is high-frequency (games push at 16-64 Hz), so we do NOT
|
||||
# rate-limit every event — that would throttle legitimate gameplay traffic.
|
||||
# Instead we throttle only FAILED-auth attempts per source IP (the only thing
|
||||
# an attacker without the token can produce). This mirrors the IP-based
|
||||
# limiter in routes/webhooks.py (~30/min) but scopes it to failures so a
|
||||
# brute-forcer is locked out after _AUTH_FAIL_LIMIT bad tokens per minute
|
||||
# while authenticated high-rate ingestion is completely unaffected.
|
||||
_AUTH_FAIL_LIMIT = 30
|
||||
_AUTH_FAIL_WINDOW = 60.0 # seconds
|
||||
_AUTH_FAIL_HITS_HARD_CAP = 1024
|
||||
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||
_auth_fail_hits: dict[str, list[float]] = defaultdict(list)
|
||||
_auth_fail_lock = threading.Lock()
|
||||
|
||||
# integration_id -> runtime stats
|
||||
_integration_stats: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def _rate_limit_key(request: Request) -> str:
|
||||
"""Pick a stable client identifier for rate-limiting.
|
||||
|
||||
When the immediate peer is loopback (assumed reverse-proxy), use the
|
||||
first ``X-Forwarded-For`` entry; otherwise use the peer's IP.
|
||||
"""
|
||||
peer = request.client.host if request.client else "unknown"
|
||||
if peer in _LOOPBACK_HOSTS:
|
||||
xff = request.headers.get("x-forwarded-for", "")
|
||||
if xff:
|
||||
return xff.split(",", 1)[0].strip() or peer
|
||||
return peer
|
||||
|
||||
|
||||
def _check_auth_fail_rate_limit(client_ip: str) -> None:
|
||||
"""Raise 429 if *client_ip* exceeded the failed-auth attempt limit."""
|
||||
now = time.time()
|
||||
window_start = now - _AUTH_FAIL_WINDOW
|
||||
with _auth_fail_lock:
|
||||
timestamps = [t for t in _auth_fail_hits[client_ip] if t > window_start]
|
||||
_auth_fail_hits[client_ip] = timestamps
|
||||
if len(timestamps) >= _AUTH_FAIL_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many failed authentication attempts. Try again later.",
|
||||
)
|
||||
|
||||
|
||||
def _record_auth_failure(client_ip: str) -> None:
|
||||
"""Record a failed-auth attempt for *client_ip* (bounded memory)."""
|
||||
now = time.time()
|
||||
window_start = now - _AUTH_FAIL_WINDOW
|
||||
with _auth_fail_lock:
|
||||
_auth_fail_hits[client_ip].append(now)
|
||||
# Periodic cleanup of stale IPs to prevent unbounded growth.
|
||||
if len(_auth_fail_hits) > 100:
|
||||
stale = [ip for ip, ts in _auth_fail_hits.items() if not ts or ts[-1] < window_start]
|
||||
for ip in stale:
|
||||
del _auth_fail_hits[ip]
|
||||
# Hard cap against an attacker spraying many distinct X-Forwarded-For
|
||||
# values; drop the oldest-touched IPs.
|
||||
if len(_auth_fail_hits) > _AUTH_FAIL_HITS_HARD_CAP:
|
||||
ordered = sorted(
|
||||
_auth_fail_hits.items(),
|
||||
key=lambda kv: kv[1][-1] if kv[1] else 0.0,
|
||||
)
|
||||
for ip, _ in ordered[: len(ordered) - _AUTH_FAIL_HITS_HARD_CAP]:
|
||||
_auth_fail_hits.pop(ip, None)
|
||||
|
||||
|
||||
def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
@@ -82,59 +153,47 @@ def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return fields
|
||||
|
||||
|
||||
def _get_prev_state(integration_id: str) -> dict[str, Any]:
|
||||
"""Get or create the prev_state dict for an integration."""
|
||||
with _integration_state_lock:
|
||||
if integration_id not in _prev_states:
|
||||
_prev_states[integration_id] = {}
|
||||
return _prev_states[integration_id]
|
||||
|
||||
|
||||
def _set_prev_state(integration_id: str, state: dict[str, Any]) -> None:
|
||||
"""Update the prev_state dict for an integration."""
|
||||
with _integration_state_lock:
|
||||
_prev_states[integration_id] = state
|
||||
|
||||
|
||||
def _record_events(integration_id: str, events: list[GameEvent]) -> None:
|
||||
"""Record event stats for an integration."""
|
||||
with _integration_state_lock:
|
||||
if integration_id not in _integration_stats:
|
||||
_integration_stats[integration_id] = {
|
||||
"event_count": 0,
|
||||
"event_counts_by_type": {},
|
||||
"last_event_time": None,
|
||||
}
|
||||
stats = _integration_stats[integration_id]
|
||||
for event in events:
|
||||
stats["event_count"] += 1
|
||||
stats["event_counts_by_type"][event.event_type] = (
|
||||
stats["event_counts_by_type"].get(event.event_type, 0) + 1
|
||||
)
|
||||
stats["last_event_time"] = event.timestamp
|
||||
|
||||
|
||||
def _get_stats(integration_id: str) -> dict[str, Any]:
|
||||
"""Get runtime stats for an integration."""
|
||||
with _integration_state_lock:
|
||||
return _integration_stats.get(
|
||||
integration_id,
|
||||
{"event_count": 0, "event_counts_by_type": {}, "last_event_time": None},
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_state(integration_id: str) -> None:
|
||||
"""Remove runtime state for a deleted integration."""
|
||||
with _integration_state_lock:
|
||||
_prev_states.pop(integration_id, None)
|
||||
_integration_stats.pop(integration_id, None)
|
||||
|
||||
|
||||
# ── Helper: convert config to response ────────────────────────────────────
|
||||
|
||||
|
||||
def _redact_secrets(adapter_config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a copy of *adapter_config* with secret values masked.
|
||||
|
||||
The adapter ``auth_token`` is a live shared secret (it authenticates the
|
||||
ingest endpoint). It is encrypted at rest, but the response builder echoes
|
||||
the in-memory *decrypted* config, so without masking any API caller
|
||||
(loopback-anonymous by default) could read the cleartext token. We never
|
||||
return the secret over the API — the edit form submits a blank value to
|
||||
keep the existing secret (see ``_merge_preserved_secrets``).
|
||||
"""
|
||||
cfg = dict(adapter_config)
|
||||
for key in _SECRET_CONFIG_KEYS:
|
||||
if cfg.get(key):
|
||||
cfg[key] = "" # mask — never echo the secret to the client
|
||||
return cfg
|
||||
|
||||
|
||||
def _merge_preserved_secrets(
|
||||
incoming: dict[str, Any] | None, existing: Any
|
||||
) -> dict[str, Any] | None:
|
||||
"""Preserve a stored secret when an update submits a blank/absent one.
|
||||
|
||||
Because the API masks secrets in responses, the edit form re-submits a
|
||||
blank value for an unchanged secret. Without this merge that blank would
|
||||
overwrite (and destroy) the stored token. A non-empty incoming value is a
|
||||
deliberate change and is kept as-is.
|
||||
"""
|
||||
if incoming is None:
|
||||
return None
|
||||
merged = dict(incoming)
|
||||
for key in _SECRET_CONFIG_KEYS:
|
||||
if not merged.get(key) and existing.adapter_config.get(key):
|
||||
merged[key] = existing.adapter_config[key]
|
||||
return merged
|
||||
|
||||
|
||||
def _config_to_response(config: Any) -> GameIntegrationResponse:
|
||||
"""Convert a GameIntegrationConfig to its API response."""
|
||||
"""Convert a GameIntegrationConfig to its API response (secrets redacted)."""
|
||||
from ledgrab.api.schemas.game_integration import EventMappingSchema
|
||||
|
||||
return GameIntegrationResponse(
|
||||
@@ -142,7 +201,7 @@ def _config_to_response(config: Any) -> GameIntegrationResponse:
|
||||
name=config.name,
|
||||
adapter_type=config.adapter_type,
|
||||
enabled=config.enabled,
|
||||
adapter_config=config.adapter_config,
|
||||
adapter_config=_redact_secrets(config.adapter_config),
|
||||
event_mappings=[
|
||||
EventMappingSchema(
|
||||
event_type=m.event_type,
|
||||
@@ -158,6 +217,8 @@ def _config_to_response(config: Any) -> GameIntegrationResponse:
|
||||
updated_at=config.updated_at,
|
||||
description=config.description,
|
||||
tags=config.tags,
|
||||
icon=getattr(config, "icon", "") or "",
|
||||
icon_color=getattr(config, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -232,6 +293,7 @@ async def create_integration(
|
||||
data: GameIntegrationCreate,
|
||||
_auth: AuthRequired,
|
||||
store: GameIntegrationStore = Depends(get_game_integration_store),
|
||||
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
|
||||
):
|
||||
"""Create a new game integration config."""
|
||||
try:
|
||||
@@ -255,9 +317,13 @@ async def create_integration(
|
||||
event_mappings=mappings,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
|
||||
fire_entity_event("game_integration", "created", config.id)
|
||||
if lol_mgr is not None:
|
||||
lol_mgr.sync(store.get_all_integrations())
|
||||
return _config_to_response(config)
|
||||
|
||||
except EntityNotFoundError as e:
|
||||
@@ -297,6 +363,7 @@ async def update_integration(
|
||||
data: GameIntegrationUpdate,
|
||||
_auth: AuthRequired,
|
||||
store: GameIntegrationStore = Depends(get_game_integration_store),
|
||||
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
|
||||
):
|
||||
"""Update a game integration config."""
|
||||
try:
|
||||
@@ -314,18 +381,30 @@ async def update_integration(
|
||||
for m in data.event_mappings
|
||||
]
|
||||
|
||||
# Preserve a stored secret when the update submits a blank token
|
||||
# (the API masks secrets, so the edit form re-sends a blank value
|
||||
# for an unchanged secret — see _merge_preserved_secrets).
|
||||
adapter_config = data.adapter_config
|
||||
if adapter_config is not None:
|
||||
existing = store.get_integration(integration_id)
|
||||
adapter_config = _merge_preserved_secrets(adapter_config, existing)
|
||||
|
||||
config = store.update_integration(
|
||||
integration_id=integration_id,
|
||||
name=data.name,
|
||||
adapter_type=data.adapter_type,
|
||||
enabled=data.enabled,
|
||||
adapter_config=data.adapter_config,
|
||||
adapter_config=adapter_config,
|
||||
event_mappings=mappings,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
|
||||
fire_entity_event("game_integration", "updated", integration_id)
|
||||
if lol_mgr is not None:
|
||||
lol_mgr.sync(store.get_all_integrations())
|
||||
return _config_to_response(config)
|
||||
|
||||
except EntityNotFoundError as e:
|
||||
@@ -346,11 +425,14 @@ async def delete_integration(
|
||||
integration_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: GameIntegrationStore = Depends(get_game_integration_store),
|
||||
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
|
||||
):
|
||||
"""Delete a game integration config."""
|
||||
try:
|
||||
store.delete_integration(integration_id)
|
||||
_cleanup_state(integration_id)
|
||||
if lol_mgr is not None:
|
||||
lol_mgr.sync(store.get_all_integrations())
|
||||
fire_entity_event("game_integration", "deleted", integration_id)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
@@ -381,7 +463,16 @@ async def ingest_event(
|
||||
called before standard API auth.
|
||||
|
||||
No AuthRequired dependency — adapter-level auth is used instead.
|
||||
|
||||
Rate limiting is scoped to FAILED-auth attempts per source IP (see
|
||||
``_check_auth_fail_rate_limit``) so legitimate high-rate ingestion is
|
||||
never throttled, but a brute-forcer is locked out after the threshold.
|
||||
"""
|
||||
client_ip = _rate_limit_key(request)
|
||||
# Block IPs that have already burned through the failed-auth budget,
|
||||
# before doing any work (cheap brute-force lockout).
|
||||
_check_auth_fail_rate_limit(client_ip)
|
||||
|
||||
try:
|
||||
config = store.get_integration(integration_id)
|
||||
except EntityNotFoundError:
|
||||
@@ -396,9 +487,18 @@ async def ingest_event(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# Adapter-level auth check
|
||||
# Adapter-level auth check. Treat ANY exception from validate_auth as an
|
||||
# auth failure (rate-limited + 403), never a 500 — a malformed/attacker-
|
||||
# controlled token must not crash the handler nor bypass the brute-force
|
||||
# lockout counter.
|
||||
headers = dict(request.headers)
|
||||
if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config):
|
||||
try:
|
||||
authed = adapter_cls.validate_auth(headers, payload.data, config.adapter_config)
|
||||
except Exception as exc:
|
||||
logger.warning("validate_auth raised for %s: %s", integration_id, exc)
|
||||
authed = False
|
||||
if not authed:
|
||||
_record_auth_failure(client_ip)
|
||||
raise HTTPException(status_code=403, detail="Adapter authentication failed")
|
||||
|
||||
# Parse payload through adapter
|
||||
|
||||
@@ -35,6 +35,8 @@ def _to_response(gradient: Gradient) -> GradientResponse:
|
||||
tags=gradient.tags,
|
||||
created_at=gradient.created_at,
|
||||
updated_at=gradient.updated_at,
|
||||
icon=getattr(gradient, "icon", "") or "",
|
||||
icon_color=getattr(gradient, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -66,6 +68,8 @@ async def create_gradient(
|
||||
stops=[s.model_dump() for s in data.stops],
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("gradient", "created", gradient.id)
|
||||
return _to_response(gradient)
|
||||
@@ -103,6 +107,8 @@ async def update_gradient(
|
||||
stops=stops,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("gradient", "updated", gradient_id)
|
||||
return _to_response(gradient)
|
||||
@@ -146,13 +152,19 @@ async def delete_gradient(
|
||||
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||
):
|
||||
"""Delete a gradient (fails if built-in or referenced by sources)."""
|
||||
_entity_name: str | None = None
|
||||
try:
|
||||
_entity_name = store.get_gradient(gradient_id).name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Check references
|
||||
for source in css_store.get_all_sources():
|
||||
if getattr(source, "gradient_id", None) == gradient_id:
|
||||
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
|
||||
store.delete_gradient(gradient_id)
|
||||
fire_entity_event("gradient", "deleted", gradient_id)
|
||||
fire_entity_event("gradient", "deleted", gradient_id, entity_name=_entity_name)
|
||||
except (ValueError, EntityNotFoundError) as e:
|
||||
status = 404 if "not found" in str(e).lower() else 400
|
||||
raise HTTPException(status_code=status, detail=str(e))
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Wiring-graph endpoints: schema registry, full topology, and dependents.
|
||||
|
||||
These power the visual graph editor (and any other client) with a single
|
||||
authoritative view of how entities are wired together:
|
||||
|
||||
* ``GET /api/v1/graph/schema`` — the connectable-field registry.
|
||||
* ``GET /api/v1/graph`` — nodes + edges + validation.
|
||||
* ``GET /api/v1/graph/dependents/{kind}/{id}`` — what references an entity.
|
||||
|
||||
All heavy logic lives in :mod:`ledgrab.api.graph_schema` (pure, unit-tested);
|
||||
this layer only gathers serialized entities from the stores and delegates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ledgrab.api import dependencies as deps
|
||||
from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.api.graph_schema import (
|
||||
ENTITY_KINDS,
|
||||
NODE_TYPE_FIELD,
|
||||
build_topology,
|
||||
extract_refs,
|
||||
find_dependents,
|
||||
remap_refs,
|
||||
schema_as_dicts,
|
||||
schema_for_kind,
|
||||
serialize_entity,
|
||||
serialize_entity_for_graph,
|
||||
validate_connection,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConnectionValidationRequest(BaseModel):
|
||||
"""A proposed wiring edit: set ``target_kind.field`` to ``source_id``."""
|
||||
|
||||
target_kind: str
|
||||
target_id: str
|
||||
field: str
|
||||
source_id: str = Field(default="", description="Empty string detaches the slot.")
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# kind → dependency getter for the store that owns that entity kind.
|
||||
_KIND_STORES: dict[str, Callable[[], Any]] = {
|
||||
"device": deps.get_device_store,
|
||||
"capture_template": deps.get_template_store,
|
||||
"pp_template": deps.get_pp_template_store,
|
||||
"audio_template": deps.get_audio_template_store,
|
||||
"pattern_template": deps.get_pattern_template_store,
|
||||
"picture_source": deps.get_picture_source_store,
|
||||
"audio_source": deps.get_audio_source_store,
|
||||
"value_source": deps.get_value_source_store,
|
||||
"color_strip_source": deps.get_color_strip_store,
|
||||
"sync_clock": deps.get_sync_clock_store,
|
||||
"output_target": deps.get_output_target_store,
|
||||
"scene_preset": deps.get_scene_preset_store,
|
||||
"automation": deps.get_automation_store,
|
||||
"cspt": deps.get_cspt_store,
|
||||
}
|
||||
|
||||
|
||||
def _gather_entities() -> dict[str, list[dict[str, Any]]]:
|
||||
"""Serialize every entity, keyed by kind. Missing stores yield ``[]``."""
|
||||
out: dict[str, list[dict[str, Any]]] = {}
|
||||
for kind, getter in _KIND_STORES.items():
|
||||
try:
|
||||
store = getter()
|
||||
models = store.get_all()
|
||||
except (
|
||||
Exception
|
||||
) as exc: # noqa: BLE001 — an uninitialized/failing store must not 500 the graph
|
||||
logger.warning("graph: store for kind %s unavailable: %s", kind, exc)
|
||||
out[kind] = []
|
||||
continue
|
||||
out[kind] = [serialize_entity_for_graph(kind, m) for m in models]
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/api/v1/graph/schema", tags=["Graph"])
|
||||
async def get_graph_schema(_auth: AuthRequired) -> dict[str, Any]:
|
||||
"""Return the authoritative registry of connectable reference fields."""
|
||||
return {
|
||||
"kinds": list(ENTITY_KINDS),
|
||||
"node_type_field": NODE_TYPE_FIELD,
|
||||
"connections": schema_as_dicts(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/v1/graph", tags=["Graph"])
|
||||
async def get_graph(_auth: AuthRequired) -> dict[str, Any]:
|
||||
"""Return the full wiring topology (nodes + edges) and a validation report."""
|
||||
entities = await run_in_threadpool(_gather_entities)
|
||||
return build_topology(entities)
|
||||
|
||||
|
||||
@router.get("/api/v1/graph/dependents/{kind}/{entity_id}", tags=["Graph"])
|
||||
async def get_graph_dependents(kind: str, entity_id: str, _auth: AuthRequired) -> dict[str, Any]:
|
||||
"""Return every entity that references ``(kind, entity_id)``."""
|
||||
if kind not in ENTITY_KINDS:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown entity kind: {kind}")
|
||||
entities = await run_in_threadpool(_gather_entities)
|
||||
return {"dependents": find_dependents(entities, kind, entity_id)}
|
||||
|
||||
|
||||
@router.post("/api/v1/graph/validate-connection", tags=["Graph"])
|
||||
async def validate_graph_connection(
|
||||
body: ConnectionValidationRequest, _auth: AuthRequired
|
||||
) -> dict[str, Any]:
|
||||
"""Validate a proposed wiring edit (existence + source kind + no cycle).
|
||||
|
||||
The graph editor calls this before persisting a drag-connect so it can
|
||||
refuse edits that would dangle a reference or create a dependency loop.
|
||||
"""
|
||||
entities = await run_in_threadpool(_gather_entities)
|
||||
ok, error = validate_connection(
|
||||
entities, body.target_kind, body.target_id, body.field, body.source_id
|
||||
)
|
||||
return {"ok": ok, "error": error}
|
||||
|
||||
|
||||
# ── Subgraph duplication (server-side blueprint instantiate) ─────────────────
|
||||
# Only these kinds are cloned. They carry no inline secrets — they *reference*
|
||||
# shared secret-bearing entities (devices, HA sources, HTTP endpoints) by id,
|
||||
# and those are NOT cloned — and they have no hardware identity to conflict
|
||||
# over. Output targets, automations, devices and integrations are out of scope.
|
||||
_DUPLICABLE_KINDS: tuple[str, ...] = ("value_source", "color_strip_source")
|
||||
_MAX_DUPLICATE = 200
|
||||
|
||||
|
||||
class DuplicateRequest(BaseModel):
|
||||
"""Duplicate a selected subgraph of value / colour-strip sources."""
|
||||
|
||||
node_ids: list[str] = Field(..., min_length=1, max_length=_MAX_DUPLICATE)
|
||||
name_suffix: str = Field(default=" (copy)", max_length=40)
|
||||
|
||||
|
||||
def _unique_name(existing: set[str], desired: str) -> str:
|
||||
"""A name not already in ``existing`` (appends ' 2', ' 3', … on collision)."""
|
||||
if desired not in existing:
|
||||
return desired
|
||||
i = 2
|
||||
while f"{desired} {i}" in existing:
|
||||
i += 1
|
||||
return f"{desired} {i}"
|
||||
|
||||
|
||||
def _duplicate_subgraph(node_ids: list[str], name_suffix: str) -> dict[str, Any]:
|
||||
"""Deep-clone selected value/colour-strip sources with new ids, rewiring
|
||||
references that point *within* the selection (shared deps are left alone)."""
|
||||
# Index every duplicable entity by id → (kind, store, model); track names.
|
||||
index: dict[str, tuple[str, Any, Any]] = {}
|
||||
existing_names: dict[str, set[str]] = {}
|
||||
for kind in _DUPLICABLE_KINDS:
|
||||
try:
|
||||
store = _KIND_STORES[kind]()
|
||||
models = store.get_all()
|
||||
except Exception as exc: # noqa: BLE001 — a failing store must not 500 the request
|
||||
logger.warning("graph.duplicate: store for %s unavailable: %s", kind, exc)
|
||||
continue
|
||||
names = existing_names.setdefault(kind, set())
|
||||
for m in models:
|
||||
mid = getattr(m, "id", None)
|
||||
mname = getattr(m, "name", None)
|
||||
if isinstance(mname, str):
|
||||
names.add(mname)
|
||||
if isinstance(mid, str) and mid:
|
||||
index[mid] = (kind, store, m)
|
||||
|
||||
selected: list[str] = []
|
||||
skipped: list[dict[str, str]] = []
|
||||
for nid in dict.fromkeys(node_ids): # de-dupe, preserve order
|
||||
if nid in index:
|
||||
selected.append(nid)
|
||||
else:
|
||||
skipped.append(
|
||||
{"id": nid, "reason": "only value and colour-strip sources can be duplicated"}
|
||||
)
|
||||
|
||||
# Pass 1 — create clones; their refs still point at the originals (valid).
|
||||
id_map: dict[str, str] = {}
|
||||
created: list[dict[str, str]] = []
|
||||
clones: list[tuple[str, Any, str]] = []
|
||||
for old_id in selected:
|
||||
kind, store, model = index[old_id]
|
||||
base = (getattr(model, "name", None) or old_id) + name_suffix
|
||||
name = _unique_name(existing_names[kind], base)
|
||||
existing_names[kind].add(name)
|
||||
try:
|
||||
new = store.clone(old_id, name)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("graph.duplicate: clone of %s %s failed: %s", kind, old_id, exc)
|
||||
skipped.append({"id": old_id, "reason": f"clone failed: {exc}"})
|
||||
continue
|
||||
id_map[old_id] = new.id
|
||||
created.append({"id": new.id, "kind": kind, "name": new.name})
|
||||
clones.append((kind, store, new.id))
|
||||
|
||||
# Pass 2 — rewrite references that point within the cloned set.
|
||||
warnings: list[dict[str, str]] = []
|
||||
for kind, store, new_id in clones:
|
||||
clone = serialize_entity(store.get(new_id))
|
||||
changed_roots: set[str] = set()
|
||||
for cf in schema_for_kind(kind):
|
||||
if remap_refs(clone, cf.field, id_map):
|
||||
changed_roots.add(cf.field.split(".", 1)[0].removesuffix("[]"))
|
||||
if not changed_roots:
|
||||
continue
|
||||
# `clone` is the FULL serialized entity, so each changed root carries a
|
||||
# complete, structurally-intact value (the whole `layers` list / bindable
|
||||
# dict) that ``update_source`` replaces or merges wholesale. (Within the
|
||||
# duplicable set the only roots that change are scalar ids, `layers` and
|
||||
# bindable slots — never a partially-built nested object.)
|
||||
updates = {root: clone[root] for root in changed_roots if root in clone}
|
||||
try:
|
||||
store.update_source(new_id, **updates)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("graph.duplicate: ref remap of %s failed: %s", new_id, exc)
|
||||
warnings.append({"id": new_id, "reason": f"reference remap failed: {exc}"})
|
||||
|
||||
# Safety net — a clone must never still reference an OLD (in-selection) id.
|
||||
for kind, store, new_id in clones:
|
||||
clone = serialize_entity(store.get(new_id))
|
||||
for cf in schema_for_kind(kind):
|
||||
if any(ref in id_map for ref in extract_refs(clone, cf.field)):
|
||||
warnings.append({"id": new_id, "reason": f"unremapped reference at {cf.field}"})
|
||||
|
||||
return {"id_map": id_map, "created": created, "skipped": skipped, "warnings": warnings}
|
||||
|
||||
|
||||
@router.post("/api/v1/graph/duplicate", tags=["Graph"])
|
||||
async def duplicate_subgraph(body: DuplicateRequest, _auth: AuthRequired) -> dict[str, Any]:
|
||||
"""Deep-clone the selected value/colour-strip sources (new ids, wiring remapped).
|
||||
|
||||
References that point *within* the selection are rewired to the new clones;
|
||||
references to entities outside it (devices, HA sources, …) stay shared with
|
||||
the originals. Only value and colour-strip sources are cloned — they carry no
|
||||
inline secrets — so any other kind in the selection is reported in ``skipped``.
|
||||
"""
|
||||
return await run_in_threadpool(_duplicate_subgraph, body.node_ids, body.name_suffix)
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
@@ -28,6 +29,7 @@ from ledgrab.storage.base_store import EntityNotFoundError
|
||||
from ledgrab.storage.home_assistant_source import HomeAssistantSource
|
||||
from ledgrab.storage.home_assistant_store import HomeAssistantStore
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.net_classify import validate_lan_host
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -37,6 +39,23 @@ router = APIRouter()
|
||||
_REDACTED_TOKEN = "***"
|
||||
|
||||
|
||||
def _validate_ha_host(host: str | None) -> None:
|
||||
"""Reject literal public/link-local/metadata IPs for a HA source host.
|
||||
|
||||
HA sources are LAN-by-design (loopback + private ranges allowed), so we
|
||||
gate the user-supplied ``host`` with the same shared classifier the LED
|
||||
device providers use (``validate_lan_host``). The HA host is stored as
|
||||
``host:port`` (e.g. ``192.168.1.100:8123``), so strip the port first via
|
||||
``urlparse`` — which also handles bracketed IPv6 literals. Hostnames /
|
||||
mDNS labels pass through (classified UNPARSEABLE). Raises ``ValueError``
|
||||
on a literal public IP, which the callers translate to HTTP 400.
|
||||
"""
|
||||
if not host:
|
||||
return
|
||||
bare_host = urlparse(f"//{host.strip()}").hostname or host.strip()
|
||||
validate_lan_host(bare_host)
|
||||
|
||||
|
||||
def _to_response(
|
||||
source: HomeAssistantSource,
|
||||
manager: HomeAssistantManager,
|
||||
@@ -55,6 +74,8 @@ def _to_response(
|
||||
entity_count=len(runtime.get_all_states()) if runtime else 0,
|
||||
description=source.description,
|
||||
tags=source.tags,
|
||||
icon=getattr(source, "icon", "") or "",
|
||||
icon_color=getattr(source, "icon_color", "") or "",
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
token=token_field,
|
||||
@@ -97,6 +118,7 @@ async def create_ha_source(
|
||||
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||
):
|
||||
try:
|
||||
_validate_ha_host(data.host)
|
||||
source = store.create_source(
|
||||
name=data.name,
|
||||
host=data.host,
|
||||
@@ -105,6 +127,8 @@ async def create_ha_source(
|
||||
entity_filters=data.entity_filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -149,6 +173,7 @@ async def update_ha_source(
|
||||
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||
):
|
||||
try:
|
||||
_validate_ha_host(data.host)
|
||||
source = store.update_source(
|
||||
source_id,
|
||||
name=data.name,
|
||||
@@ -158,6 +183,8 @@ async def update_ha_source(
|
||||
entity_filters=data.entity_filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
|
||||
@@ -316,6 +343,7 @@ async def get_ha_status(
|
||||
name=source.name,
|
||||
connected=connected,
|
||||
entity_count=status["entity_count"] if status else 0,
|
||||
host=source.host or "",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"""HTTP endpoint routes: CRUD + one-shot test."""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_http_endpoint_store,
|
||||
)
|
||||
from ledgrab.api.schemas.http_endpoints import (
|
||||
HTTPEndpointCreate,
|
||||
HTTPEndpointListResponse,
|
||||
HTTPEndpointResponse,
|
||||
HTTPEndpointUpdate,
|
||||
HTTPTestRequest,
|
||||
HTTPTestResponse,
|
||||
)
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
from ledgrab.storage.http_endpoint import HTTPEndpoint
|
||||
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.safe_source import safe_request_bounded, validate_polling_url
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _warn_if_plaintext_token(url: str, auth_token: str, *, action: str) -> None:
|
||||
"""Log a warning when an auth token would be sent over plaintext http://."""
|
||||
if auth_token and url.lower().startswith("http://"):
|
||||
logger.warning(
|
||||
"HTTP endpoint %s: auth_token will be sent over plaintext http:// to %s. "
|
||||
"Anyone on the network path can read it. Consider https:// if the "
|
||||
"target supports TLS.",
|
||||
action,
|
||||
url,
|
||||
)
|
||||
|
||||
|
||||
def _to_response(endpoint: HTTPEndpoint) -> HTTPEndpointResponse:
|
||||
return HTTPEndpointResponse(
|
||||
id=endpoint.id,
|
||||
name=endpoint.name,
|
||||
url=endpoint.url,
|
||||
method=endpoint.method,
|
||||
auth_token_set=bool(endpoint.auth_token),
|
||||
headers=dict(endpoint.headers),
|
||||
timeout_s=endpoint.timeout_s,
|
||||
description=endpoint.description,
|
||||
tags=endpoint.tags,
|
||||
icon=getattr(endpoint, "icon", "") or "",
|
||||
icon_color=getattr(endpoint, "icon_color", "") or "",
|
||||
created_at=endpoint.created_at,
|
||||
updated_at=endpoint.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/http/endpoints",
|
||||
response_model=HTTPEndpointListResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def list_http_endpoints(
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
endpoints = store.get_all_endpoints()
|
||||
return HTTPEndpointListResponse(
|
||||
endpoints=[_to_response(e) for e in endpoints],
|
||||
count=len(endpoints),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/http/endpoints",
|
||||
response_model=HTTPEndpointResponse,
|
||||
status_code=201,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def create_http_endpoint(
|
||||
data: HTTPEndpointCreate,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
validate_polling_url(data.url)
|
||||
_warn_if_plaintext_token(data.url, data.auth_token, action="create")
|
||||
try:
|
||||
endpoint = store.create_endpoint(
|
||||
name=data.name,
|
||||
url=data.url,
|
||||
method=data.method,
|
||||
auth_token=data.auth_token,
|
||||
headers=data.headers,
|
||||
timeout_s=data.timeout_s,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
fire_entity_event("http_endpoint", "created", endpoint.id)
|
||||
return _to_response(endpoint)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/http/endpoints/{endpoint_id}",
|
||||
response_model=HTTPEndpointResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def get_http_endpoint(
|
||||
endpoint_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
try:
|
||||
endpoint = store.get_endpoint(endpoint_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
return _to_response(endpoint)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/http/endpoints/{endpoint_id}",
|
||||
response_model=HTTPEndpointResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def update_http_endpoint(
|
||||
endpoint_id: str,
|
||||
data: HTTPEndpointUpdate,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
if data.url is not None:
|
||||
validate_polling_url(data.url)
|
||||
final_url = data.url
|
||||
final_token = data.auth_token
|
||||
if final_url is None or final_token is None:
|
||||
try:
|
||||
existing = store.get_endpoint(endpoint_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
if final_url is None:
|
||||
final_url = existing.url
|
||||
if final_token is None:
|
||||
final_token = existing.auth_token
|
||||
_warn_if_plaintext_token(final_url, final_token, action="update")
|
||||
try:
|
||||
endpoint = store.update_endpoint(
|
||||
endpoint_id,
|
||||
name=data.name,
|
||||
url=data.url,
|
||||
method=data.method,
|
||||
auth_token=data.auth_token,
|
||||
headers=data.headers,
|
||||
timeout_s=data.timeout_s,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
fire_entity_event("http_endpoint", "updated", endpoint.id)
|
||||
return _to_response(endpoint)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/api/v1/http/endpoints/{endpoint_id}",
|
||||
status_code=204,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def delete_http_endpoint(
|
||||
endpoint_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
try:
|
||||
store.delete_endpoint(endpoint_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
fire_entity_event("http_endpoint", "deleted", endpoint_id)
|
||||
|
||||
|
||||
async def _run_http_test(
|
||||
method: str,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
timeout_s: float,
|
||||
) -> HTTPTestResponse:
|
||||
"""Shared one-shot fetch + response shaping for both test endpoints."""
|
||||
try:
|
||||
status, body_bytes, error = await safe_request_bounded(
|
||||
method, url, headers=headers, timeout=timeout_s
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
return HTTPTestResponse(success=False, error=f"Unexpected error: {type(exc).__name__}")
|
||||
|
||||
if error and status == 0:
|
||||
return HTTPTestResponse(success=False, error=error)
|
||||
|
||||
try:
|
||||
body_text = body_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
body_text = body_bytes.decode("utf-8", errors="replace")
|
||||
try:
|
||||
body_json = json.loads(body_text) if body_text else None
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
body_json = None
|
||||
|
||||
preview = body_text[:500] if body_text else None
|
||||
is_success = 200 <= status < 300
|
||||
return HTTPTestResponse(
|
||||
success=is_success,
|
||||
status_code=status,
|
||||
body_preview=preview,
|
||||
body_json=body_json,
|
||||
error=None if is_success else f"HTTP {status}",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/http/endpoints/test",
|
||||
response_model=HTTPTestResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def test_http_endpoint(
|
||||
data: HTTPTestRequest,
|
||||
_auth: AuthRequired,
|
||||
):
|
||||
"""One-shot fetch to validate URL + auth before saving."""
|
||||
headers = dict(data.headers)
|
||||
if data.auth_token and not any(k.lower() == "authorization" for k in headers):
|
||||
headers["Authorization"] = f"Bearer {data.auth_token}"
|
||||
return await _run_http_test(data.method, data.url, headers, data.timeout_s)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/http/endpoints/{endpoint_id}/test",
|
||||
response_model=HTTPTestResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def test_saved_http_endpoint(
|
||||
endpoint_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
"""Run the stored endpoint configuration (URL + auth + headers + timeout).
|
||||
|
||||
Useful for the "test" button on the endpoint card: avoids the user
|
||||
having to open the editor and re-enter the auth token (which is
|
||||
never returned to the client).
|
||||
"""
|
||||
try:
|
||||
endpoint = store.get_endpoint(endpoint_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
return await _run_http_test(
|
||||
endpoint.method,
|
||||
endpoint.url,
|
||||
endpoint.build_request_headers(),
|
||||
endpoint.timeout_s,
|
||||
)
|
||||
@@ -42,9 +42,13 @@ def _to_response(source: MQTTSource, manager: MQTTManager) -> MQTTSourceResponse
|
||||
password_set=bool(source.password),
|
||||
client_id=source.client_id,
|
||||
base_topic=source.base_topic,
|
||||
publish_ha_discovery=getattr(source, "publish_ha_discovery", False),
|
||||
discovery_prefix=getattr(source, "discovery_prefix", "homeassistant"),
|
||||
connected=runtime.is_connected if runtime else False,
|
||||
description=source.description,
|
||||
tags=source.tags,
|
||||
icon=getattr(source, "icon", "") or "",
|
||||
icon_color=getattr(source, "icon_color", "") or "",
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
)
|
||||
@@ -88,11 +92,17 @@ async def create_mqtt_source(
|
||||
password=data.password,
|
||||
client_id=data.client_id,
|
||||
base_topic=data.base_topic,
|
||||
publish_ha_discovery=data.publish_ha_discovery,
|
||||
discovery_prefix=data.discovery_prefix,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
# Publish HA discovery if the new source opted in.
|
||||
await manager.sync_discovery(source.id)
|
||||
fire_entity_event("mqtt_source", "created", source.id)
|
||||
return _to_response(source, manager)
|
||||
|
||||
@@ -137,14 +147,20 @@ async def update_mqtt_source(
|
||||
password=data.password,
|
||||
client_id=data.client_id,
|
||||
base_topic=data.base_topic,
|
||||
publish_ha_discovery=data.publish_ha_discovery,
|
||||
discovery_prefix=data.discovery_prefix,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"MQTT source {source_id} not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
await manager.update_source(source_id)
|
||||
# Reconcile HA discovery (publish if enabled, clear if turned off).
|
||||
await manager.sync_discovery(source_id)
|
||||
fire_entity_event("mqtt_source", "updated", source.id)
|
||||
return _to_response(source, manager)
|
||||
|
||||
@@ -156,6 +172,9 @@ async def delete_mqtt_source(
|
||||
store: MQTTSourceStore = Depends(get_mqtt_store),
|
||||
manager: MQTTManager = Depends(get_mqtt_manager),
|
||||
):
|
||||
# Clear any HA discovery configs (needs the source still present to build
|
||||
# the exact retained topics) before deleting the row.
|
||||
await manager.disable_discovery(source_id)
|
||||
try:
|
||||
store.delete_source(source_id)
|
||||
except EntityNotFoundError:
|
||||
|
||||
@@ -9,17 +9,27 @@ from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_device_store,
|
||||
get_mqtt_store,
|
||||
get_output_target_store,
|
||||
get_processor_manager,
|
||||
get_value_source_store,
|
||||
)
|
||||
from ledgrab.api.schemas.output_targets import (
|
||||
HALightMappingSchema,
|
||||
HALightOutputTargetCreate,
|
||||
HALightOutputTargetResponse,
|
||||
HALightOutputTargetUpdate,
|
||||
LedOutputTargetCreate,
|
||||
LedOutputTargetResponse,
|
||||
LedOutputTargetUpdate,
|
||||
OutputTargetCreate,
|
||||
OutputTargetListResponse,
|
||||
OutputTargetResponse,
|
||||
OutputTargetUpdate,
|
||||
Z2MLightMappingSchema,
|
||||
Z2MLightOutputTargetCreate,
|
||||
Z2MLightOutputTargetResponse,
|
||||
Z2MLightOutputTargetUpdate,
|
||||
)
|
||||
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||
from ledgrab.storage import DeviceStore
|
||||
@@ -29,10 +39,18 @@ from ledgrab.storage.ha_light_output_target import (
|
||||
HALightMapping,
|
||||
HALightOutputTarget,
|
||||
)
|
||||
from ledgrab.storage.z2m_light_output_target import (
|
||||
Z2MLightMapping,
|
||||
Z2MLightOutputTarget,
|
||||
)
|
||||
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
|
||||
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
|
||||
from ._mqtt_validation import validate_mqtt_source_exists
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -52,8 +70,12 @@ def _led_target_to_response(target: WledOutputTarget) -> LedOutputTargetResponse
|
||||
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
|
||||
adaptive_fps=target.adaptive_fps,
|
||||
protocol=target.protocol,
|
||||
max_milliamps=target.max_milliamps,
|
||||
milliamps_per_led=target.milliamps_per_led,
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
icon=getattr(target, "icon", "") or "",
|
||||
icon_color=getattr(target, "icon_color", "") or "",
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
)
|
||||
@@ -66,8 +88,11 @@ def _ha_light_target_to_response(
|
||||
return HALightOutputTargetResponse(
|
||||
id=target.id,
|
||||
name=target.name,
|
||||
ha_source_id=target.ha_source_id,
|
||||
color_strip_source_id=target.color_strip_source_id,
|
||||
ha_source_id=target.ha_source_id or "",
|
||||
source_kind=target.source_kind if target.source_kind in ("css", "color_vs") else "css",
|
||||
# Defensive coalesce — older records stored via resolve_ref may hold None.
|
||||
color_strip_source_id=target.color_strip_source_id or "",
|
||||
color_value_source_id=target.color_value_source_id or "",
|
||||
brightness=target.brightness.to_dict(),
|
||||
ha_light_mappings=[
|
||||
HALightMappingSchema(
|
||||
@@ -82,34 +107,173 @@ def _ha_light_target_to_response(
|
||||
transition=target.transition.to_dict(),
|
||||
color_tolerance=target.color_tolerance.to_dict(),
|
||||
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
|
||||
stop_action=target.stop_action,
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
icon=getattr(target, "icon", "") or "",
|
||||
icon_color=getattr(target, "icon_color", "") or "",
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _z2m_light_target_to_response(
|
||||
target: Z2MLightOutputTarget,
|
||||
) -> Z2MLightOutputTargetResponse:
|
||||
"""Convert a Z2MLightOutputTarget to Z2MLightOutputTargetResponse."""
|
||||
return Z2MLightOutputTargetResponse(
|
||||
id=target.id,
|
||||
name=target.name,
|
||||
mqtt_source_id=target.mqtt_source_id or "",
|
||||
source_kind=target.source_kind if target.source_kind in ("css", "color_vs") else "css",
|
||||
color_strip_source_id=target.color_strip_source_id or "",
|
||||
color_value_source_id=target.color_value_source_id or "",
|
||||
brightness=target.brightness.to_dict(),
|
||||
z2m_light_mappings=[
|
||||
Z2MLightMappingSchema(
|
||||
friendly_name=m.friendly_name,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=m.brightness_scale.to_dict(),
|
||||
)
|
||||
for m in target.light_mappings
|
||||
],
|
||||
base_topic=target.base_topic,
|
||||
update_rate=target.update_rate.to_dict(),
|
||||
transition=target.transition.to_dict(),
|
||||
color_tolerance=target.color_tolerance.to_dict(),
|
||||
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
|
||||
stop_action=target.stop_action if target.stop_action in ("none", "turn_off") else "none",
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
icon=getattr(target, "icon", "") or "",
|
||||
icon_color=getattr(target, "icon_color", "") or "",
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _validate_color_value_source(
|
||||
value_source_store: ValueSourceStore, color_value_source_id: str
|
||||
) -> None:
|
||||
"""Ensure the referenced ValueSource exists and returns colour."""
|
||||
if not color_value_source_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="color_value_source_id is required when source_kind='color_vs'",
|
||||
)
|
||||
try:
|
||||
source = value_source_store.get_source(color_value_source_id)
|
||||
except (ValueError, EntityNotFoundError):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Color value source {color_value_source_id} not found",
|
||||
)
|
||||
if source.to_dict().get("return_type") != "color":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Value source {color_value_source_id} does not return colour "
|
||||
"(return_type must be 'color')"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_TARGET_RESPONSE_BUILDERS: dict = {
|
||||
WledOutputTarget: _led_target_to_response,
|
||||
HALightOutputTarget: _ha_light_target_to_response,
|
||||
Z2MLightOutputTarget: _z2m_light_target_to_response,
|
||||
}
|
||||
|
||||
|
||||
def _assert_target_response_coverage() -> None:
|
||||
"""Verify the response registry covers every concrete OutputTarget subclass.
|
||||
|
||||
Runs at module import. Surfaces a missing builder eagerly instead of
|
||||
letting a request fall through to the previous silent fallback (which
|
||||
used to return a defaults-filled LedOutputTargetResponse and quietly
|
||||
misshape the payload for unknown target types).
|
||||
"""
|
||||
expected = {WledOutputTarget, HALightOutputTarget, Z2MLightOutputTarget}
|
||||
registered = set(_TARGET_RESPONSE_BUILDERS.keys())
|
||||
missing = expected - registered
|
||||
extra = registered - expected
|
||||
if missing or extra:
|
||||
problems = []
|
||||
if missing:
|
||||
problems.append(f"missing builders: {sorted(c.__name__ for c in missing)}")
|
||||
if extra:
|
||||
problems.append(f"unregistered classes: {sorted(c.__name__ for c in extra)}")
|
||||
raise RuntimeError(
|
||||
"_TARGET_RESPONSE_BUILDERS is out of sync with the OutputTarget "
|
||||
"subclass set: " + "; ".join(problems)
|
||||
)
|
||||
|
||||
|
||||
_assert_target_response_coverage()
|
||||
|
||||
|
||||
def _target_to_response(target) -> OutputTargetResponse:
|
||||
"""Convert any OutputTarget to the appropriate typed response."""
|
||||
if isinstance(target, WledOutputTarget):
|
||||
return _led_target_to_response(target)
|
||||
elif isinstance(target, HALightOutputTarget):
|
||||
return _ha_light_target_to_response(target)
|
||||
else:
|
||||
# Fallback for unknown types — use LED response with defaults
|
||||
return LedOutputTargetResponse(
|
||||
id=target.id,
|
||||
name=target.name,
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
"""Convert any OutputTarget to the appropriate typed response.
|
||||
|
||||
Dispatches via :data:`_TARGET_RESPONSE_BUILDERS` keyed by concrete
|
||||
subclass. Raises ``RuntimeError`` for an unregistered subclass —
|
||||
coverage is asserted at import, so this should never fire in
|
||||
practice; if it does, the storage layer added a new OutputTarget
|
||||
subclass without a matching response builder here.
|
||||
"""
|
||||
builder = _TARGET_RESPONSE_BUILDERS.get(type(target))
|
||||
if builder is None:
|
||||
raise RuntimeError(
|
||||
f"No response builder registered for OutputTarget subclass " f"{type(target).__name__}"
|
||||
)
|
||||
return builder(target)
|
||||
|
||||
|
||||
# ===== CRUD ENDPOINTS =====
|
||||
|
||||
|
||||
def _build_ha_mappings(
|
||||
payload: list[HALightMappingSchema] | None,
|
||||
) -> list[HALightMapping] | None:
|
||||
if not payload:
|
||||
return None
|
||||
return [
|
||||
HALightMapping(
|
||||
entity_id=m.entity_id,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||
)
|
||||
for m in payload
|
||||
]
|
||||
|
||||
|
||||
def _build_z2m_mappings(
|
||||
payload: list[Z2MLightMappingSchema] | None,
|
||||
) -> list[Z2MLightMapping] | None:
|
||||
if not payload:
|
||||
return None
|
||||
return [
|
||||
Z2MLightMapping(
|
||||
friendly_name=m.friendly_name,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||
)
|
||||
for m in payload
|
||||
]
|
||||
|
||||
|
||||
def _validate_device_exists(device_store: DeviceStore, device_id: str) -> None:
|
||||
if not device_id:
|
||||
return
|
||||
try:
|
||||
device_store.get_device(device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
|
||||
)
|
||||
@@ -119,53 +283,72 @@ async def create_target(
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
device_store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
value_source_store: ValueSourceStore = Depends(get_value_source_store),
|
||||
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
|
||||
):
|
||||
"""Create a new output target."""
|
||||
try:
|
||||
# Validate device exists if provided
|
||||
device_id = getattr(data, "device_id", "")
|
||||
if device_id:
|
||||
try:
|
||||
device_store.get_device(device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
|
||||
|
||||
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
|
||||
ha_mappings = (
|
||||
[
|
||||
HALightMapping(
|
||||
entity_id=m.entity_id,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||
)
|
||||
for m in ha_light_mappings_raw
|
||||
]
|
||||
if ha_light_mappings_raw
|
||||
else None
|
||||
)
|
||||
|
||||
# Create in store
|
||||
target = target_store.create_target(
|
||||
match data:
|
||||
case LedOutputTargetCreate():
|
||||
_validate_device_exists(device_store, data.device_id)
|
||||
target = target_store.create_wled_target(
|
||||
name=data.name,
|
||||
target_type=data.target_type,
|
||||
device_id=device_id,
|
||||
color_strip_source_id=getattr(data, "color_strip_source_id", ""),
|
||||
brightness=getattr(data, "brightness", 1.0),
|
||||
fps=getattr(data, "fps", 30),
|
||||
keepalive_interval=getattr(data, "keepalive_interval", 1.0),
|
||||
state_check_interval=getattr(data, "state_check_interval", 30),
|
||||
min_brightness_threshold=getattr(data, "min_brightness_threshold", 0),
|
||||
adaptive_fps=getattr(data, "adaptive_fps", False),
|
||||
protocol=getattr(data, "protocol", "ddp"),
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
ha_source_id=getattr(data, "ha_source_id", ""),
|
||||
ha_light_mappings=ha_mappings,
|
||||
update_rate=getattr(data, "update_rate", 2.0),
|
||||
transition=getattr(data, "transition", 0.5),
|
||||
color_tolerance=getattr(data, "color_tolerance", 5),
|
||||
device_id=data.device_id,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
brightness=data.brightness,
|
||||
fps=data.fps,
|
||||
keepalive_interval=data.keepalive_interval,
|
||||
state_check_interval=data.state_check_interval,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
adaptive_fps=data.adaptive_fps,
|
||||
protocol=data.protocol,
|
||||
max_milliamps=data.max_milliamps,
|
||||
milliamps_per_led=data.milliamps_per_led,
|
||||
)
|
||||
case HALightOutputTargetCreate():
|
||||
if data.source_kind == "color_vs":
|
||||
_validate_color_value_source(value_source_store, data.color_value_source_id)
|
||||
target = target_store.create_ha_light_target(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
ha_source_id=data.ha_source_id,
|
||||
source_kind=data.source_kind,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
color_value_source_id=data.color_value_source_id,
|
||||
brightness=data.brightness,
|
||||
ha_light_mappings=_build_ha_mappings(data.ha_light_mappings),
|
||||
update_rate=data.update_rate,
|
||||
transition=data.transition,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
color_tolerance=data.color_tolerance,
|
||||
stop_action=data.stop_action,
|
||||
)
|
||||
case Z2MLightOutputTargetCreate():
|
||||
if data.source_kind == "color_vs":
|
||||
_validate_color_value_source(value_source_store, data.color_value_source_id)
|
||||
validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
|
||||
target = target_store.create_z2m_light_target(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
mqtt_source_id=data.mqtt_source_id,
|
||||
source_kind=data.source_kind,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
color_value_source_id=data.color_value_source_id,
|
||||
brightness=data.brightness,
|
||||
z2m_light_mappings=_build_z2m_mappings(data.z2m_light_mappings),
|
||||
base_topic=data.base_topic,
|
||||
update_rate=data.update_rate,
|
||||
transition=data.transition,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
color_tolerance=data.color_tolerance,
|
||||
stop_action=data.stop_action,
|
||||
)
|
||||
case _: # pragma: no cover — Pydantic discriminator already ensures one of the three
|
||||
raise HTTPException(status_code=400, detail="Unknown target_type")
|
||||
|
||||
# Register in processor manager
|
||||
try:
|
||||
@@ -233,6 +416,18 @@ async def get_target(
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
def _resolve_effective_color_vs_id(
|
||||
target_store: OutputTargetStore, target_id: str, payload_id: str | None
|
||||
) -> str:
|
||||
if payload_id is not None:
|
||||
return payload_id
|
||||
try:
|
||||
existing = target_store.get_target(target_id)
|
||||
except ValueError:
|
||||
return ""
|
||||
return getattr(existing, "color_value_source_id", "") or ""
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]
|
||||
)
|
||||
@@ -243,90 +438,165 @@ async def update_target(
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
device_store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
value_source_store: ValueSourceStore = Depends(get_value_source_store),
|
||||
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
|
||||
):
|
||||
"""Update a output target."""
|
||||
try:
|
||||
# Validate device exists if changing
|
||||
device_id = getattr(data, "device_id", None)
|
||||
if device_id is not None and device_id:
|
||||
try:
|
||||
device_store.get_device(device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
|
||||
css_changed = False
|
||||
brightness_changed = False
|
||||
settings_changed = False
|
||||
device_changed = False
|
||||
|
||||
# Build HA light mappings if provided
|
||||
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
|
||||
ha_mappings = None
|
||||
if ha_light_mappings_raw is not None:
|
||||
ha_mappings = [
|
||||
HALightMapping(
|
||||
entity_id=m.entity_id,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||
)
|
||||
for m in ha_light_mappings_raw
|
||||
]
|
||||
|
||||
# Update in store
|
||||
target = target_store.update_target(
|
||||
target_id=target_id,
|
||||
match data:
|
||||
case LedOutputTargetUpdate():
|
||||
if data.device_id:
|
||||
_validate_device_exists(device_store, data.device_id)
|
||||
target = target_store.update_wled_target(
|
||||
target_id,
|
||||
name=data.name,
|
||||
device_id=device_id,
|
||||
color_strip_source_id=getattr(data, "color_strip_source_id", None),
|
||||
brightness=getattr(data, "brightness", None),
|
||||
fps=getattr(data, "fps", None),
|
||||
keepalive_interval=getattr(data, "keepalive_interval", None),
|
||||
state_check_interval=getattr(data, "state_check_interval", None),
|
||||
min_brightness_threshold=getattr(data, "min_brightness_threshold", None),
|
||||
adaptive_fps=getattr(data, "adaptive_fps", None),
|
||||
protocol=getattr(data, "protocol", None),
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
ha_source_id=getattr(data, "ha_source_id", None),
|
||||
ha_light_mappings=ha_mappings,
|
||||
update_rate=getattr(data, "update_rate", None),
|
||||
transition=getattr(data, "transition", None),
|
||||
color_tolerance=getattr(data, "color_tolerance", None),
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
device_id=data.device_id,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
brightness=data.brightness,
|
||||
fps=data.fps,
|
||||
keepalive_interval=data.keepalive_interval,
|
||||
state_check_interval=data.state_check_interval,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
adaptive_fps=data.adaptive_fps,
|
||||
protocol=data.protocol,
|
||||
max_milliamps=data.max_milliamps,
|
||||
milliamps_per_led=data.milliamps_per_led,
|
||||
)
|
||||
css_changed = data.color_strip_source_id is not None
|
||||
brightness_changed = data.brightness is not None
|
||||
settings_changed = any(
|
||||
v is not None
|
||||
for v in (
|
||||
data.fps,
|
||||
data.keepalive_interval,
|
||||
data.state_check_interval,
|
||||
data.min_brightness_threshold,
|
||||
data.adaptive_fps,
|
||||
data.brightness,
|
||||
data.max_milliamps,
|
||||
data.milliamps_per_led,
|
||||
)
|
||||
)
|
||||
device_changed = data.device_id is not None
|
||||
case HALightOutputTargetUpdate():
|
||||
# Validate color VS when switching into / staying in color_vs mode
|
||||
if data.source_kind == "color_vs" or (
|
||||
data.source_kind is None and data.color_value_source_id
|
||||
):
|
||||
effective_id = _resolve_effective_color_vs_id(
|
||||
target_store, target_id, data.color_value_source_id
|
||||
)
|
||||
_validate_color_value_source(value_source_store, effective_id)
|
||||
target = target_store.update_ha_light_target(
|
||||
target_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
ha_source_id=data.ha_source_id,
|
||||
source_kind=data.source_kind,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
color_value_source_id=data.color_value_source_id,
|
||||
brightness=data.brightness,
|
||||
ha_light_mappings=_build_ha_mappings(data.ha_light_mappings),
|
||||
update_rate=data.update_rate,
|
||||
transition=data.transition,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
color_tolerance=data.color_tolerance,
|
||||
stop_action=data.stop_action,
|
||||
)
|
||||
css_changed = data.color_strip_source_id is not None
|
||||
brightness_changed = data.brightness is not None
|
||||
settings_changed = any(
|
||||
v is not None
|
||||
for v in (
|
||||
data.source_kind,
|
||||
data.color_value_source_id,
|
||||
data.brightness,
|
||||
data.update_rate,
|
||||
data.transition,
|
||||
data.min_brightness_threshold,
|
||||
data.color_tolerance,
|
||||
data.ha_light_mappings,
|
||||
data.stop_action,
|
||||
)
|
||||
)
|
||||
case Z2MLightOutputTargetUpdate():
|
||||
if data.source_kind == "color_vs" or (
|
||||
data.source_kind is None and data.color_value_source_id
|
||||
):
|
||||
effective_id = _resolve_effective_color_vs_id(
|
||||
target_store, target_id, data.color_value_source_id
|
||||
)
|
||||
_validate_color_value_source(value_source_store, effective_id)
|
||||
if data.mqtt_source_id:
|
||||
validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
|
||||
target = target_store.update_z2m_light_target(
|
||||
target_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
mqtt_source_id=data.mqtt_source_id,
|
||||
source_kind=data.source_kind,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
color_value_source_id=data.color_value_source_id,
|
||||
brightness=data.brightness,
|
||||
z2m_light_mappings=_build_z2m_mappings(data.z2m_light_mappings),
|
||||
base_topic=data.base_topic,
|
||||
update_rate=data.update_rate,
|
||||
transition=data.transition,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
color_tolerance=data.color_tolerance,
|
||||
stop_action=data.stop_action,
|
||||
)
|
||||
css_changed = data.color_strip_source_id is not None
|
||||
brightness_changed = data.brightness is not None
|
||||
settings_changed = any(
|
||||
v is not None
|
||||
for v in (
|
||||
data.source_kind,
|
||||
data.color_value_source_id,
|
||||
data.mqtt_source_id,
|
||||
data.brightness,
|
||||
data.base_topic,
|
||||
data.update_rate,
|
||||
data.transition,
|
||||
data.min_brightness_threshold,
|
||||
data.color_tolerance,
|
||||
data.z2m_light_mappings,
|
||||
data.stop_action,
|
||||
)
|
||||
)
|
||||
case _: # pragma: no cover — Pydantic discriminator already ensures one of the three
|
||||
raise HTTPException(status_code=400, detail="Unknown target_type")
|
||||
|
||||
# Sync processor manager (run in thread — css release/acquire can block)
|
||||
color_strip_source_id = getattr(data, "color_strip_source_id", None)
|
||||
fps = getattr(data, "fps", None)
|
||||
keepalive_interval = getattr(data, "keepalive_interval", None)
|
||||
state_check_interval = getattr(data, "state_check_interval", None)
|
||||
min_brightness_threshold = getattr(data, "min_brightness_threshold", None)
|
||||
adaptive_fps = getattr(data, "adaptive_fps", None)
|
||||
update_rate = getattr(data, "update_rate", None)
|
||||
transition = getattr(data, "transition", None)
|
||||
color_tolerance = getattr(data, "color_tolerance", None)
|
||||
brightness = getattr(data, "brightness", None)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
target.sync_with_manager,
|
||||
manager,
|
||||
settings_changed=(
|
||||
fps is not None
|
||||
or keepalive_interval is not None
|
||||
or state_check_interval is not None
|
||||
or min_brightness_threshold is not None
|
||||
or adaptive_fps is not None
|
||||
or update_rate is not None
|
||||
or transition is not None
|
||||
or color_tolerance is not None
|
||||
or ha_light_mappings_raw is not None
|
||||
or brightness is not None
|
||||
),
|
||||
css_changed=color_strip_source_id is not None,
|
||||
brightness_changed=brightness is not None,
|
||||
settings_changed=settings_changed,
|
||||
css_changed=css_changed,
|
||||
brightness_changed=brightness_changed,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.debug("Processor config update skipped for target %s: %s", target_id, e)
|
||||
pass
|
||||
|
||||
# Device change requires async stop -> swap -> start cycle
|
||||
if device_id is not None:
|
||||
# LED-only: device change requires async stop -> swap -> start cycle
|
||||
if device_changed and isinstance(target, WledOutputTarget):
|
||||
try:
|
||||
await manager.update_target_device(target_id, target.device_id)
|
||||
except ValueError as e:
|
||||
@@ -354,6 +624,13 @@ async def delete_target(
|
||||
):
|
||||
"""Delete a output target. Stops processing first if active."""
|
||||
try:
|
||||
# Resolve name before deletion for the audit record.
|
||||
_entity_name: str | None = None
|
||||
try:
|
||||
_entity_name = target_store.get_target(target_id).name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Stop processing if running
|
||||
try:
|
||||
await manager.stop_processing(target_id)
|
||||
@@ -371,7 +648,7 @@ async def delete_target(
|
||||
# Delete from store
|
||||
target_store.delete_target(target_id)
|
||||
|
||||
fire_entity_event("output_target", "deleted", target_id)
|
||||
fire_entity_event("output_target", "deleted", target_id, entity_name=_entity_name)
|
||||
logger.info(f"Deleted target {target_id}")
|
||||
|
||||
except ValueError as e:
|
||||
|
||||
@@ -12,6 +12,7 @@ from ledgrab.api.dependencies import (
|
||||
get_picture_source_store,
|
||||
get_processor_manager,
|
||||
)
|
||||
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||
from ledgrab.api.schemas.output_targets import (
|
||||
BulkTargetRequest,
|
||||
BulkTargetResponse,
|
||||
@@ -28,6 +29,7 @@ from ledgrab.storage.color_strip_source import (
|
||||
from ledgrab.storage.picture_source_store import PictureSourceStore
|
||||
from ledgrab.storage.wled_output_target import WledOutputTarget
|
||||
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -35,6 +37,23 @@ logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _record_capture(action: str, target_id: str, target_name: str | None, message: str) -> None:
|
||||
"""Best-effort audit record for a capture start/stop action."""
|
||||
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||
|
||||
rec = get_module_recorder()
|
||||
if rec is not None:
|
||||
rec.record(
|
||||
category=ActivityCategory.CAPTURE,
|
||||
action=action,
|
||||
severity=ActivitySeverity.INFO,
|
||||
entity_type="output_target",
|
||||
entity_id=target_id,
|
||||
entity_name=sanitize_display(target_name) if target_name else None,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
# ===== BULK PROCESSING CONTROL ENDPOINTS =====
|
||||
|
||||
|
||||
@@ -53,10 +72,18 @@ async def bulk_start_processing(
|
||||
|
||||
for target_id in body.ids:
|
||||
try:
|
||||
target_store.get_target(target_id)
|
||||
_tgt = target_store.get_target(target_id)
|
||||
await manager.start_processing(target_id)
|
||||
started.append(target_id)
|
||||
logger.info(f"Bulk start: started processing for target {target_id}")
|
||||
_tgt_name_raw = getattr(_tgt, "name", None)
|
||||
_tgt_safe = sanitize_display(_tgt_name_raw) if _tgt_name_raw else None
|
||||
_record_capture(
|
||||
"capture.started",
|
||||
target_id,
|
||||
_tgt_safe,
|
||||
f"Capture started for target '{_tgt_safe or target_id}' (bulk)",
|
||||
)
|
||||
except ValueError as e:
|
||||
errors[target_id] = str(e)
|
||||
except RuntimeError as e:
|
||||
@@ -78,6 +105,7 @@ async def bulk_start_processing(
|
||||
async def bulk_stop_processing(
|
||||
body: BulkTargetRequest,
|
||||
_auth: AuthRequired,
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors."""
|
||||
@@ -89,6 +117,18 @@ async def bulk_stop_processing(
|
||||
await manager.stop_processing(target_id)
|
||||
stopped.append(target_id)
|
||||
logger.info(f"Bulk stop: stopped processing for target {target_id}")
|
||||
_tgt_name: str | None = None
|
||||
try:
|
||||
_tgt_name = target_store.get_target(target_id).name
|
||||
except Exception:
|
||||
pass
|
||||
_tgt_name_safe = sanitize_display(_tgt_name) if _tgt_name else None
|
||||
_record_capture(
|
||||
"capture.stopped",
|
||||
target_id,
|
||||
_tgt_name_safe,
|
||||
f"Capture stopped for target '{_tgt_name_safe or target_id}' (bulk)",
|
||||
)
|
||||
except ValueError as e:
|
||||
errors[target_id] = str(e)
|
||||
except Exception as e:
|
||||
@@ -112,11 +152,19 @@ async def start_processing(
|
||||
logger.info("Start processing requested for target %s", target_id)
|
||||
try:
|
||||
# Verify target exists in store
|
||||
target_store.get_target(target_id)
|
||||
target = target_store.get_target(target_id)
|
||||
|
||||
await manager.start_processing(target_id)
|
||||
|
||||
logger.info(f"Started processing for target {target_id}")
|
||||
_tgt_name_raw2 = getattr(target, "name", None)
|
||||
_tgt_safe2 = sanitize_display(_tgt_name_raw2) if _tgt_name_raw2 else None
|
||||
_record_capture(
|
||||
"capture.started",
|
||||
target_id,
|
||||
_tgt_safe2,
|
||||
f"Capture started for target '{_tgt_safe2 or target_id}'",
|
||||
)
|
||||
return {"status": "started", "target_id": target_id}
|
||||
|
||||
except ValueError as e:
|
||||
@@ -137,6 +185,7 @@ async def start_processing(
|
||||
async def stop_processing(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Stop processing for a output target."""
|
||||
@@ -144,6 +193,18 @@ async def stop_processing(
|
||||
await manager.stop_processing(target_id)
|
||||
|
||||
logger.info(f"Stopped processing for target {target_id}")
|
||||
_target_name: str | None = None
|
||||
try:
|
||||
_target_name = target_store.get_target(target_id).name
|
||||
except Exception:
|
||||
pass
|
||||
_target_name_safe = sanitize_display(_target_name) if _target_name else None
|
||||
_record_capture(
|
||||
"capture.stopped",
|
||||
target_id,
|
||||
_target_name_safe,
|
||||
f"Capture stopped for target '{_target_name_safe or target_id}'",
|
||||
)
|
||||
return {"status": "stopped", "target_id": target_id}
|
||||
|
||||
except ValueError as e:
|
||||
@@ -335,6 +396,35 @@ async def get_overlay_status(
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
# ===== HA LIGHT — MANUAL TURN OFF =====
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/ha-light/turn-off", tags=["Processing"])
|
||||
async def turn_off_ha_light_target(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Turn off all HA light entities mapped by the target.
|
||||
|
||||
Works regardless of whether the target's processor is running. Useful
|
||||
when ``stop_action`` is ``"none"`` and lights were left on after a stop.
|
||||
"""
|
||||
try:
|
||||
# Verify target exists
|
||||
target_store.get_target(target_id)
|
||||
count = await manager.turn_off_ha_light_target(target_id)
|
||||
return {"status": "ok", "target_id": target_id, "entities": count}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to turn off HA lights: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# ===== HA LIGHT COLOR PREVIEW WEBSOCKET =====
|
||||
|
||||
|
||||
@@ -377,6 +467,75 @@ async def ha_light_colors_ws(
|
||||
manager.remove_ha_light_ws_client(target_id, websocket)
|
||||
|
||||
|
||||
# ===== Z2M LIGHT — MANUAL TURN OFF =====
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/z2m-light/turn-off", tags=["Processing"])
|
||||
async def turn_off_z2m_light_target(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Publish OFF to all Z2M bulbs mapped by the target.
|
||||
|
||||
Works regardless of whether the target's processor is running. Useful
|
||||
when ``stop_action`` is ``"none"`` and bulbs were left on after a stop.
|
||||
"""
|
||||
try:
|
||||
target_store.get_target(target_id)
|
||||
count = await manager.turn_off_z2m_light_target(target_id)
|
||||
return {"status": "ok", "target_id": target_id, "entities": count}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to turn off Z2M lights: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# ===== Z2M LIGHT COLOR PREVIEW WEBSOCKET =====
|
||||
|
||||
|
||||
@router.websocket("/api/v1/output-targets/{target_id}/z2m-light/ws")
|
||||
async def z2m_light_colors_ws(
|
||||
websocket: WebSocket,
|
||||
target_id: str,
|
||||
):
|
||||
"""WebSocket for live Z2M bulb colour preview.
|
||||
|
||||
Streams: {"type":"colors_update","colors":{friendly_name:{r,g,b,hex},...}}
|
||||
at the target's update_rate. Auth via first-message handshake.
|
||||
"""
|
||||
from ledgrab.api.auth import accept_and_authenticate_ws
|
||||
|
||||
if await accept_and_authenticate_ws(websocket) is None:
|
||||
return
|
||||
|
||||
manager: ProcessorManager = get_processor_manager()
|
||||
|
||||
try:
|
||||
proc = manager._processors.get(target_id)
|
||||
if not proc or not proc.is_running:
|
||||
await websocket.close(code=4003, reason="Target not running")
|
||||
return
|
||||
except Exception as e:
|
||||
await websocket.close(code=4004, reason=str(e))
|
||||
return
|
||||
|
||||
try:
|
||||
manager.add_z2m_light_ws_client(target_id, websocket)
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except (RuntimeError, ConnectionError) as e:
|
||||
logger.debug("ws closed in z2m-light client: %s", e)
|
||||
finally:
|
||||
manager.remove_z2m_light_ws_client(target_id, websocket)
|
||||
|
||||
|
||||
# ===== LED PREVIEW WEBSOCKET =====
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ def _pat_template_to_response(t) -> PatternTemplateResponse:
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=t.tags,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -83,6 +85,8 @@ async def create_pattern_template(
|
||||
rectangles=rectangles,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("pattern_template", "created", template.id)
|
||||
return _pat_template_to_response(template)
|
||||
@@ -139,6 +143,8 @@ async def update_pattern_template(
|
||||
rectangles=rectangles,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("pattern_template", "updated", template_id)
|
||||
return _pat_template_to_response(template)
|
||||
|
||||
@@ -12,6 +12,7 @@ from fastapi.responses import Response
|
||||
from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_color_strip_store,
|
||||
get_picture_source_store,
|
||||
get_output_target_store,
|
||||
get_pp_template_store,
|
||||
@@ -37,6 +38,7 @@ from ledgrab.api.schemas.picture_sources import (
|
||||
)
|
||||
from ledgrab.core.capture_engines import EngineRegistry
|
||||
from ledgrab.core.filters import FilterRegistry, ImagePool
|
||||
from ledgrab.storage.color_strip_store import ColorStripStore
|
||||
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||
from ledgrab.storage.template_store import TemplateStore
|
||||
from ledgrab.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||
@@ -63,6 +65,8 @@ _RESPONSE_MAP = {
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
display_index=s.display_index,
|
||||
capture_template_id=s.capture_template_id,
|
||||
target_fps=s.target_fps,
|
||||
@@ -74,6 +78,8 @@ _RESPONSE_MAP = {
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
source_stream_id=s.source_stream_id,
|
||||
postprocessing_template_id=s.postprocessing_template_id,
|
||||
),
|
||||
@@ -84,6 +90,8 @@ _RESPONSE_MAP = {
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
image_asset_id=s.image_asset_id,
|
||||
),
|
||||
VideoCaptureSource: lambda s: VideoPictureSourceResponse(
|
||||
@@ -93,6 +101,8 @@ _RESPONSE_MAP = {
|
||||
tags=s.tags,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
video_asset_id=s.video_asset_id,
|
||||
loop=s.loop,
|
||||
playback_speed=s.playback_speed,
|
||||
@@ -361,11 +371,18 @@ async def delete_picture_source(
|
||||
_auth: AuthRequired,
|
||||
store: PictureSourceStore = Depends(get_picture_source_store),
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||
):
|
||||
"""Delete a picture source."""
|
||||
_entity_name: str | None = None
|
||||
try:
|
||||
# Check if any target references this stream
|
||||
target_names = store.get_targets_referencing(stream_id, target_store)
|
||||
_entity_name = store.get_stream(stream_id).name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Check if any target transitively references this stream via a CSS
|
||||
target_names = store.get_targets_referencing(stream_id, target_store, css_store)
|
||||
if target_names:
|
||||
names = ", ".join(target_names)
|
||||
raise HTTPException(
|
||||
@@ -373,8 +390,18 @@ async def delete_picture_source(
|
||||
detail=f"Cannot delete picture source: it is assigned to target(s): {names}. "
|
||||
"Please reassign those targets before deleting.",
|
||||
)
|
||||
# Block when any CSS still references this picture source, even if no
|
||||
# target depends on it — deletion would leave the CSS broken.
|
||||
css_refs = css_store.get_referencing_picture_source(stream_id)
|
||||
if css_refs:
|
||||
css_names = ", ".join(css.name for css in css_refs)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot delete picture source: it is used by color strip source(s): "
|
||||
f"{css_names}. Please reassign or delete those first.",
|
||||
)
|
||||
store.delete_stream(stream_id)
|
||||
fire_entity_event("picture_source", "deleted", stream_id)
|
||||
fire_entity_event("picture_source", "deleted", stream_id, entity_name=_entity_name)
|
||||
except HTTPException:
|
||||
raise
|
||||
except EntityNotFoundError as e:
|
||||
|
||||
@@ -49,6 +49,9 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=t.tags,
|
||||
icon=getattr(t, "icon", "") or "",
|
||||
icon_color=getattr(t, "icon_color", "") or "",
|
||||
is_builtin=getattr(t, "is_builtin", False),
|
||||
)
|
||||
|
||||
|
||||
@@ -86,6 +89,8 @@ async def create_pp_template(
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("pp_template", "created", template.id)
|
||||
return _pp_template_to_response(template)
|
||||
@@ -143,6 +148,8 @@ async def update_pp_template(
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
fire_entity_event("pp_template", "updated", template_id)
|
||||
return _pp_template_to_response(template)
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
"""User preferences routes — dashboard layout + notification settings + daylight tz.
|
||||
|
||||
The dashboard layout schema is owned by the frontend (open registry of
|
||||
section/cell keys); the backend treats the value as an opaque JSON blob,
|
||||
validates it's a dict with a `version` field, and persists it under the
|
||||
`dashboard_layout` settings key.
|
||||
|
||||
Notification preferences are validated server-side via Pydantic so the
|
||||
backend can read them when deciding whether to start the background
|
||||
discovery watcher.
|
||||
|
||||
Daylight timezone is a single global IANA tz name shared by every
|
||||
daylight value-source / color-strip-source. Stored as
|
||||
``{"value": "Europe/Berlin"}`` under the ``daylight_timezone`` key, with
|
||||
empty/missing meaning "use system local time".
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.api.dependencies import get_database
|
||||
from ledgrab.api.schemas.preferences import NotificationPreferences
|
||||
from ledgrab.core.processing.daylight_settings import (
|
||||
DAYLIGHT_TIMEZONE_KEY,
|
||||
get_daylight_timezone,
|
||||
set_daylight_timezone,
|
||||
)
|
||||
from ledgrab.storage.database import Database
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
|
||||
_NOTIFICATION_PREFS_KEY = "notification_preferences"
|
||||
_CARD_MODES_KEY = "card_modes"
|
||||
_ONBOARDING_KEY = "onboarded"
|
||||
|
||||
|
||||
class DaylightTimezonePreference(BaseModel):
|
||||
"""Global IANA timezone applied to every daylight cycle source."""
|
||||
|
||||
timezone: str = Field("", description="IANA timezone name; empty = system local")
|
||||
|
||||
|
||||
def load_notification_preferences(db: Database | None = None) -> NotificationPreferences:
|
||||
"""Read notification prefs, returning defaults when unset or corrupt.
|
||||
|
||||
Used by both the route handler and `main.lifespan` (so the discovery
|
||||
watcher can decide whether to start without going through HTTP).
|
||||
"""
|
||||
if db is None:
|
||||
from ledgrab.api.dependencies import get_database as _get_db
|
||||
|
||||
db = _get_db()
|
||||
raw = db.get_setting(_NOTIFICATION_PREFS_KEY)
|
||||
if not raw:
|
||||
return NotificationPreferences()
|
||||
try:
|
||||
return NotificationPreferences.model_validate(raw)
|
||||
except Exception as e:
|
||||
logger.warning("Stored notification preferences invalid (%s); using defaults", e)
|
||||
return NotificationPreferences()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/preferences/dashboard-layout",
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def get_dashboard_layout(
|
||||
_: AuthRequired,
|
||||
db: Database = Depends(get_database),
|
||||
) -> dict[str, Any]:
|
||||
"""Read the saved dashboard layout. Returns an empty object when no
|
||||
layout has been saved yet — the frontend falls back to its built-in
|
||||
default in that case."""
|
||||
value = db.get_setting(_DASHBOARD_LAYOUT_KEY)
|
||||
return value if value is not None else {}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/preferences/dashboard-layout",
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def put_dashboard_layout(
|
||||
_: AuthRequired,
|
||||
body: dict[str, Any] = Body(...),
|
||||
db: Database = Depends(get_database),
|
||||
) -> dict[str, bool]:
|
||||
"""Save the dashboard layout. The body must be a JSON object with a
|
||||
numeric `version` field; everything else is treated as opaque payload
|
||||
that the frontend will validate on read."""
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(status_code=422, detail="Body must be a JSON object")
|
||||
if not isinstance(body.get("version"), int):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Layout must include a numeric 'version' field",
|
||||
)
|
||||
db.set_setting(_DASHBOARD_LAYOUT_KEY, body)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/api/v1/preferences/dashboard-layout",
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def delete_dashboard_layout(
|
||||
_: AuthRequired,
|
||||
db: Database = Depends(get_database),
|
||||
) -> dict[str, bool]:
|
||||
"""Delete the saved layout — frontend will revert to the default
|
||||
on next load. Used by the 'Reset' button when the user wants
|
||||
to clear the server-side override entirely."""
|
||||
db.set_setting(_DASHBOARD_LAYOUT_KEY, {})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notification preferences
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/preferences/notifications",
|
||||
response_model=NotificationPreferences,
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def get_notification_preferences(
|
||||
_: AuthRequired,
|
||||
db: Database = Depends(get_database),
|
||||
) -> NotificationPreferences:
|
||||
"""Read notification prefs, returning defaults when unset.
|
||||
|
||||
Defaults: device_offline=both, device_online/discovered=snack,
|
||||
device_lost=none, background discovery on, 10 s startup grace,
|
||||
5 s flap debounce.
|
||||
"""
|
||||
return load_notification_preferences(db)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/preferences/notifications",
|
||||
response_model=NotificationPreferences,
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def put_notification_preferences(
|
||||
_: AuthRequired,
|
||||
body: NotificationPreferences,
|
||||
db: Database = Depends(get_database),
|
||||
) -> NotificationPreferences:
|
||||
"""Persist the notification prefs. Pydantic enforces channel
|
||||
enum + grace/debounce ranges so a bad client cannot poison
|
||||
the stored value."""
|
||||
db.set_setting(_NOTIFICATION_PREFS_KEY, body.model_dump())
|
||||
logger.info(
|
||||
"Notification preferences updated (background_discovery=%s, " "channels=%s)",
|
||||
body.background_discovery_enabled,
|
||||
body.channels.model_dump(),
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Card presentation modes (per-surface comfortable/compact/dense)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_VALID_CARD_MODES = {"comfortable", "compact", "dense", "row"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/preferences/card-modes",
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def get_card_modes(
|
||||
_: AuthRequired,
|
||||
db: Database = Depends(get_database),
|
||||
) -> dict[str, Any]:
|
||||
"""Read the saved card-mode preferences. Returns an empty object when
|
||||
nothing has been saved yet — the frontend falls back to the default
|
||||
mode ("compact") for every surface in that case."""
|
||||
value = db.get_setting(_CARD_MODES_KEY)
|
||||
return value if value is not None else {}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/preferences/card-modes",
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def put_card_modes(
|
||||
_: AuthRequired,
|
||||
body: dict[str, Any] = Body(...),
|
||||
db: Database = Depends(get_database),
|
||||
) -> dict[str, bool]:
|
||||
"""Save card-mode preferences. The body must be a JSON object shaped
|
||||
like ``{"version": 1, "surfaces": {"<surface>": "<mode>", …}}``.
|
||||
|
||||
The surface registry is intentionally open (any string accepted) so
|
||||
new card surfaces can adopt the toggle without a server migration.
|
||||
Invalid mode values are rejected to prevent a bad client from
|
||||
poisoning the stored value."""
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(status_code=422, detail="Body must be a JSON object")
|
||||
if not isinstance(body.get("version"), int):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Body must include a numeric 'version' field",
|
||||
)
|
||||
surfaces = body.get("surfaces", {})
|
||||
if not isinstance(surfaces, dict):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="'surfaces' must be an object mapping surface keys to modes",
|
||||
)
|
||||
for key, mode in surfaces.items():
|
||||
if not isinstance(key, str) or not key:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Surface keys must be non-empty strings (got {key!r})",
|
||||
)
|
||||
if mode not in _VALID_CARD_MODES:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"Surface {key!r} has invalid mode {mode!r}; "
|
||||
f"expected one of {sorted(_VALID_CARD_MODES)}"
|
||||
),
|
||||
)
|
||||
db.set_setting(_CARD_MODES_KEY, body)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/api/v1/preferences/card-modes",
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def delete_card_modes(
|
||||
_: AuthRequired,
|
||||
db: Database = Depends(get_database),
|
||||
) -> dict[str, bool]:
|
||||
"""Delete saved card-mode preferences — every surface reverts to the
|
||||
frontend default on next load."""
|
||||
db.set_setting(_CARD_MODES_KEY, {})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Daylight timezone (global)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/preferences/daylight-timezone",
|
||||
response_model=DaylightTimezonePreference,
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def get_daylight_timezone_preference(
|
||||
_: AuthRequired,
|
||||
) -> DaylightTimezonePreference:
|
||||
"""Return the global daylight cycle timezone (empty = system local)."""
|
||||
return DaylightTimezonePreference(timezone=get_daylight_timezone())
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/preferences/daylight-timezone",
|
||||
response_model=DaylightTimezonePreference,
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def put_daylight_timezone_preference(
|
||||
_: AuthRequired,
|
||||
body: DaylightTimezonePreference,
|
||||
) -> DaylightTimezonePreference:
|
||||
"""Persist the global daylight cycle timezone.
|
||||
|
||||
The string is stored verbatim — clients should send a valid IANA name
|
||||
(e.g. ``Europe/Berlin``) or an empty string for "use server local".
|
||||
Daylight streams pick up the new value within ~1 second.
|
||||
"""
|
||||
saved = set_daylight_timezone(body.timezone)
|
||||
logger.info("Daylight timezone updated: %r", saved or "<system local>")
|
||||
return DaylightTimezonePreference(timezone=saved)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Onboarding flag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OnboardingPreference(BaseModel):
|
||||
"""Persistent first-run onboarding flag."""
|
||||
|
||||
onboarded: bool = Field(
|
||||
False,
|
||||
description="True once the user has completed the first-run wizard.",
|
||||
)
|
||||
completed_at: str | None = Field(
|
||||
None,
|
||||
description="ISO timestamp of when onboarding was first marked complete; null otherwise.",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/preferences/onboarding",
|
||||
response_model=OnboardingPreference,
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def get_onboarding(
|
||||
_: AuthRequired,
|
||||
db: Database = Depends(get_database),
|
||||
) -> OnboardingPreference:
|
||||
"""Return the first-run onboarding status.
|
||||
|
||||
Defaults to ``{onboarded: false, completed_at: null}`` when the flag has
|
||||
never been set.
|
||||
"""
|
||||
raw = db.get_setting(_ONBOARDING_KEY)
|
||||
if not raw:
|
||||
return OnboardingPreference()
|
||||
try:
|
||||
return OnboardingPreference.model_validate(raw)
|
||||
except Exception as exc:
|
||||
logger.warning("Stored onboarding preference invalid (%s); using default", exc)
|
||||
return OnboardingPreference()
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/preferences/onboarding",
|
||||
response_model=OnboardingPreference,
|
||||
tags=["Preferences"],
|
||||
)
|
||||
async def put_onboarding(
|
||||
_: AuthRequired,
|
||||
body: OnboardingPreference,
|
||||
db: Database = Depends(get_database),
|
||||
) -> OnboardingPreference:
|
||||
"""Persist the onboarding flag.
|
||||
|
||||
When ``onboarded`` is set to ``true`` and ``completed_at`` is not provided,
|
||||
the server stamps the current UTC time automatically.
|
||||
When ``onboarded`` is ``false``, ``completed_at`` is cleared.
|
||||
"""
|
||||
if body.onboarded and body.completed_at is None:
|
||||
body = OnboardingPreference(
|
||||
onboarded=True,
|
||||
completed_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
elif not body.onboarded:
|
||||
body = OnboardingPreference(onboarded=False, completed_at=None)
|
||||
|
||||
db.set_setting(_ONBOARDING_KEY, body.model_dump())
|
||||
logger.info("Onboarding flag updated: onboarded=%s", body.onboarded)
|
||||
return body
|
||||
|
||||
|
||||
__all__ = ["router", "DAYLIGHT_TIMEZONE_KEY"]
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Scene playlist API routes — CRUD plus start/stop/state cycling control."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||
from ledgrab.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_playlist_engine,
|
||||
get_scene_playlist_store,
|
||||
get_scene_preset_store,
|
||||
)
|
||||
from ledgrab.api.schemas.scene_playlists import (
|
||||
PlaylistRuntimeStateSchema,
|
||||
ScenePlaylistCreate,
|
||||
ScenePlaylistListResponse,
|
||||
ScenePlaylistResponse,
|
||||
ScenePlaylistUpdate,
|
||||
)
|
||||
from ledgrab.core.scenes.playlist_engine import PlaylistEngine, PlaylistError
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
from ledgrab.storage.scene_playlist import PlaylistItem, ScenePlaylist
|
||||
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
|
||||
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _playlist_to_response(playlist: ScenePlaylist, engine: PlaylistEngine) -> ScenePlaylistResponse:
|
||||
return ScenePlaylistResponse(
|
||||
id=playlist.id,
|
||||
name=playlist.name,
|
||||
description=playlist.description,
|
||||
items=[
|
||||
{"scene_preset_id": i.scene_preset_id, "duration_seconds": i.duration_seconds}
|
||||
for i in playlist.items
|
||||
],
|
||||
loop=playlist.loop,
|
||||
shuffle=playlist.shuffle,
|
||||
order=playlist.order,
|
||||
tags=playlist.tags,
|
||||
icon=getattr(playlist, "icon", "") or "",
|
||||
icon_color=getattr(playlist, "icon_color", "") or "",
|
||||
is_running=engine.get_running_playlist_id() == playlist.id,
|
||||
created_at=playlist.created_at,
|
||||
updated_at=playlist.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _items_from_schema(items) -> list[PlaylistItem]:
|
||||
return [
|
||||
PlaylistItem(scene_preset_id=i.scene_preset_id, duration_seconds=i.duration_seconds)
|
||||
for i in items
|
||||
]
|
||||
|
||||
|
||||
def _validate_preset_refs(items, preset_store: ScenePresetStore) -> None:
|
||||
"""Reject playlist items that reference a non-existent scene preset."""
|
||||
for item in items:
|
||||
try:
|
||||
preset_store.get_preset(item.scene_preset_id)
|
||||
except (ValueError, EntityNotFoundError):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Scene preset not found: {item.scene_preset_id}",
|
||||
)
|
||||
|
||||
|
||||
# ===== CRUD =====
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/scene-playlists",
|
||||
response_model=ScenePlaylistResponse,
|
||||
tags=["Scene Playlists"],
|
||||
status_code=201,
|
||||
)
|
||||
async def create_scene_playlist(
|
||||
data: ScenePlaylistCreate,
|
||||
_auth: AuthRequired,
|
||||
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||
preset_store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||
):
|
||||
"""Create a new scene playlist."""
|
||||
_validate_preset_refs(data.items, preset_store)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
playlist = ScenePlaylist(
|
||||
id=f"playlist_{uuid.uuid4().hex[:8]}",
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
items=_items_from_schema(data.items),
|
||||
loop=data.loop,
|
||||
shuffle=data.shuffle,
|
||||
order=store.count(),
|
||||
tags=data.tags if data.tags is not None else [],
|
||||
icon=data.icon or "",
|
||||
icon_color=data.icon_color or "",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
try:
|
||||
playlist = store.create_playlist(playlist)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
fire_entity_event("scene_playlist", "created", playlist.id)
|
||||
return _playlist_to_response(playlist, engine)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/scene-playlists",
|
||||
response_model=ScenePlaylistListResponse,
|
||||
tags=["Scene Playlists"],
|
||||
)
|
||||
async def list_scene_playlists(
|
||||
_auth: AuthRequired,
|
||||
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||
):
|
||||
"""List all scene playlists plus the current cycling state."""
|
||||
playlists = store.get_all_playlists()
|
||||
return ScenePlaylistListResponse(
|
||||
playlists=[_playlist_to_response(p, engine) for p in playlists],
|
||||
count=len(playlists),
|
||||
state=PlaylistRuntimeStateSchema(**engine.get_state()),
|
||||
)
|
||||
|
||||
|
||||
# NOTE: the static ``/state`` path is declared before ``/{playlist_id}`` so it
|
||||
# is matched first and not swallowed by the path parameter.
|
||||
@router.get(
|
||||
"/api/v1/scene-playlists/state",
|
||||
response_model=PlaylistRuntimeStateSchema,
|
||||
tags=["Scene Playlists"],
|
||||
)
|
||||
async def get_playlist_state(
|
||||
_auth: AuthRequired,
|
||||
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||
):
|
||||
"""Get the current playlist cycling state (idle if nothing is running)."""
|
||||
return PlaylistRuntimeStateSchema(**engine.get_state())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/scene-playlists/{playlist_id}",
|
||||
response_model=ScenePlaylistResponse,
|
||||
tags=["Scene Playlists"],
|
||||
)
|
||||
async def get_scene_playlist(
|
||||
playlist_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||
):
|
||||
"""Get a single scene playlist."""
|
||||
try:
|
||||
playlist = store.get_playlist(playlist_id)
|
||||
except (ValueError, EntityNotFoundError) as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
return _playlist_to_response(playlist, engine)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/scene-playlists/{playlist_id}",
|
||||
response_model=ScenePlaylistResponse,
|
||||
tags=["Scene Playlists"],
|
||||
)
|
||||
async def update_scene_playlist(
|
||||
playlist_id: str,
|
||||
data: ScenePlaylistUpdate,
|
||||
_auth: AuthRequired,
|
||||
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||
preset_store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||
):
|
||||
"""Update a scene playlist's metadata, items, and playback flags."""
|
||||
new_items = None
|
||||
if data.items is not None:
|
||||
_validate_preset_refs(data.items, preset_store)
|
||||
new_items = _items_from_schema(data.items)
|
||||
|
||||
try:
|
||||
playlist = store.update_playlist(
|
||||
playlist_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
items=new_items,
|
||||
loop=data.loop,
|
||||
shuffle=data.shuffle,
|
||||
order=data.order,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except (ValueError, EntityNotFoundError) as e:
|
||||
raise HTTPException(
|
||||
status_code=404 if "not found" in str(e).lower() else 400, detail=str(e)
|
||||
)
|
||||
|
||||
fire_entity_event("scene_playlist", "updated", playlist_id)
|
||||
return _playlist_to_response(playlist, engine)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/api/v1/scene-playlists/{playlist_id}",
|
||||
status_code=204,
|
||||
tags=["Scene Playlists"],
|
||||
)
|
||||
async def delete_scene_playlist(
|
||||
playlist_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||
):
|
||||
"""Delete a scene playlist (stops it first if it is currently cycling)."""
|
||||
_entity_name: str | None = None
|
||||
try:
|
||||
_entity_name = store.get_playlist(playlist_id).name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
store.delete_playlist(playlist_id)
|
||||
except (ValueError, EntityNotFoundError) as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
await engine.stop_if_running(playlist_id)
|
||||
fire_entity_event("scene_playlist", "deleted", playlist_id, entity_name=_entity_name)
|
||||
|
||||
|
||||
# ===== Cycling control =====
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/scene-playlists/{playlist_id}/start",
|
||||
response_model=PlaylistRuntimeStateSchema,
|
||||
tags=["Scene Playlists"],
|
||||
)
|
||||
async def start_scene_playlist(
|
||||
playlist_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||
):
|
||||
"""Start cycling a playlist (stops any currently-running playlist first)."""
|
||||
try:
|
||||
store.get_playlist(playlist_id)
|
||||
except (ValueError, EntityNotFoundError) as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
try:
|
||||
await engine.start_playlist(playlist_id)
|
||||
except PlaylistError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
fire_entity_event("scene_playlist", "updated", playlist_id)
|
||||
|
||||
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||
|
||||
rec = get_module_recorder()
|
||||
if rec is not None:
|
||||
_pl_name: str | None = None
|
||||
try:
|
||||
_pl_name = store.get_playlist(playlist_id).name
|
||||
except Exception:
|
||||
pass
|
||||
_safe_pl_name = sanitize_display(_pl_name) if _pl_name else None
|
||||
rec.record(
|
||||
category=ActivityCategory.CAPTURE,
|
||||
action="playlist.started",
|
||||
severity=ActivitySeverity.INFO,
|
||||
entity_type="scene_playlist",
|
||||
entity_id=playlist_id,
|
||||
entity_name=_safe_pl_name,
|
||||
message=f"Playlist '{_safe_pl_name or playlist_id}' started",
|
||||
)
|
||||
|
||||
return PlaylistRuntimeStateSchema(**engine.get_state())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/scene-playlists/stop",
|
||||
response_model=PlaylistRuntimeStateSchema,
|
||||
tags=["Scene Playlists"],
|
||||
)
|
||||
async def stop_scene_playlist(
|
||||
_auth: AuthRequired,
|
||||
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||
):
|
||||
"""Stop the active playlist (leaves the last applied scene in place)."""
|
||||
stopped_id = engine.get_running_playlist_id()
|
||||
_stopped_name: str | None = None
|
||||
if stopped_id:
|
||||
try:
|
||||
_stopped_name = store.get_playlist(stopped_id).name
|
||||
except Exception:
|
||||
pass
|
||||
await engine.stop()
|
||||
if stopped_id:
|
||||
fire_entity_event("scene_playlist", "updated", stopped_id)
|
||||
|
||||
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||
|
||||
rec = get_module_recorder()
|
||||
if rec is not None:
|
||||
_safe_stopped_name = sanitize_display(_stopped_name) if _stopped_name else None
|
||||
rec.record(
|
||||
category=ActivityCategory.CAPTURE,
|
||||
action="playlist.stopped",
|
||||
severity=ActivitySeverity.INFO,
|
||||
entity_type="scene_playlist",
|
||||
entity_id=stopped_id,
|
||||
entity_name=_safe_stopped_name,
|
||||
message=f"Playlist '{_safe_stopped_name or stopped_id}' stopped",
|
||||
)
|
||||
|
||||
return PlaylistRuntimeStateSchema(**engine.get_state())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user