Compare commits

..

25 Commits

Author SHA1 Message Date
alexei.dolgolyov 6aeda935f1 chore: release v0.8.1
Build Android APK / build-android (push) Failing after 11s
Build Release / create-release (push) Successful in 6s
Build Release / build-docker (push) Successful in 2m57s
Build Release / build-linux (push) Successful in 1m49s
Build Release / build-windows (push) Successful in 3m35s
Build Release / publish-release (push) Successful in 2s
2026-05-28 23:35:35 +03:00
alexei.dolgolyov a5effba553 feat: aggregated snapshot + wiring-graph APIs, MQTT device brokers
Backend
- snapshot: GET /api/v1/snapshot aggregates targets, devices, sources,
  presets and system into one payload for the HA coordinator, collapsing
  the prior ~2N+M request fan-out; per-section ?include= gating.
- graph: GET /api/v1/graph{,/schema,/dependents} backed by a pure,
  unit-tested graph_schema engine — one authoritative connectable-field
  registry so the editor no longer hard-codes topology in two places.
- devices: thread mqtt_source_id through DeviceCreate/Update/Response and
  the routes for multi-broker MQTT; shared validate_mqtt_source_exists
  (_mqtt_validation.py) reused by device + output-target routes; stop
  update_device masking intentional 4xx as 500.
- shutdown: bound uvicorn graceful-shutdown via GRACEFUL_SHUTDOWN_TIMEOUT
  (shared by __main__, android_entry, demo) so a lingering events WebSocket
  can't strand LED targets or block process exit.
- access log: structured _access_log middleware attributing each request to
  its authenticated token label (never the secret); uvicorn access_log off.

Frontend
- graph editor: generic schema-driven port/edge rendering, layout and
  connection handling; service-worker refresh.
- device modals: MQTT broker EntitySelect for device_type=mqtt in add-device
  and settings, wired into load/save/validate/dirty-check/clone.
- i18n: en/ru/zh keys.

Tests: graph routes + schema, snapshot routes, access log, mqtt_source_id
device regressions, bounded-shutdown entrypoint. 1614 passed.
2026-05-28 22:51:04 +03:00
alexei.dolgolyov b83a72e63f chore: release v0.8.0
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 8s
Build Release / build-linux (push) Successful in 3m8s
Build Release / build-docker (push) Successful in 4m11s
Build Release / build-windows (push) Successful in 4m55s
Build Release / publish-release (push) Successful in 1s
2026-05-28 17:48:06 +03:00
alexei.dolgolyov 0d840adfca fix(ctypes): share wintypes.MSG with platform_detector to avoid argtype races
WindowsShutdownGuard was binding user32.GetMessageW.argtypes with
POINTER(_MSG) (project-local struct), while PlatformDetector's display-
power monitor binds it with POINTER(wintypes.MSG). argtypes is a
mutable global on the cached WinDLL handle, so whichever module
imported last won, and the other module's byref() then tripped
Python 3.13's strict argtype check with
"expected LP_MSG instance instead of pointer to _MSG".

The two structs are byte-identical (same field types in the same
order, just pt vs pt_x/pt_y naming) and we never touch the pt field,
so aliasing _MSG to wintypes.MSG eliminates the conflict — both
modules now bind the same POINTER class, the writes become idempotent,
and the full test suite passes regardless of import order.

CI runs on Linux so this never fired in release builds, but it broke
the local Windows test run.
2026-05-28 17:36:19 +03:00
alexei.dolgolyov 1f959932c1 fix(notification): allow clearing the sound on per-app overrides and main row
Previously, both the per-app override sound dropdown and the main
notification sound row only attached an EntitySelect when at least
one sound asset was registered — and never with allowNone. That left
users with no way to pick "no sound" once an entry existed, and made
the override dropdown silently inert before any assets were added.

Always construct the EntitySelect (so an empty assets list still
renders a usable, searchable input) and pass allowNone with the
localized none label so "no sound" is a first-class choice in both
the override list and the main row.
2026-05-28 17:28:34 +03:00
alexei.dolgolyov 10eb24b2ce docs: dashboard innerHTML reconciliation review notes
Design doc capturing the architectural pattern behind the perf-card
flicker (every innerHTML rewrite tears down the subtree, restarting
CSS animations / dropping focus / wasting layout) and the two
drivers — poll timers and server:* push events. Inventories every
remaining latent site in perf-charts.ts / dashboard.ts /
entity-events.ts / game-integration.ts, walks the decision ladder
from a setInnerHtmlIfChanged helper through hand-rolled cell
components to a Lit migration, and lands on Lit for polling-heavy
modules with entity-events.ts tab reconciliation sequenced ahead of
the dashboard cards because of its higher blast radius.

Planning artifact only — no implementation here.
2026-05-28 17:26:56 +03:00
alexei.dolgolyov 66b85b0175 fix(css-editor): persist notification_sound + notification_volume
The CSS editor modal collected every other notification field on
save but silently dropped notification_sound and notification_volume,
so toggling them in the modal had no effect on the saved strip.
Include both in the save payload alongside the existing notification
fields.
2026-05-28 17:26:44 +03:00
alexei.dolgolyov bc42604045 ci(release): publish release only after every build job uploads assets
create-release now creates the release as a draft so users never see
a release page that's missing artifacts (or, worse, missing the
sha256 sidecars that the in-app updater requires). A new
publish-release job runs after create-release, build-windows,
build-linux, and build-docker all succeed, and PATCHes the release
to draft=false in one step. If any build fails, the draft stays
hidden and can be deleted manually.
2026-05-28 17:26:28 +03:00
alexei.dolgolyov 3645216669 feat(icons): spectrum aperture icon set + dedicated tray variant
Regenerate the LedGrab icon family from a single Pillow script
(build/generate_icon.py): a rounded-square aperture traced by a
continuous RGB color-wheel stroke over a vignette canvas with a soft
chromatic bloom. 4x supersampled then downsampled per output for
crispness. Outputs 192/512 standard, 512 maskable (safe-area padded
for PWA round-crops), and a new 256 transparent-background tray
variant so the taskbar icon reads cleanly against light themes
instead of showing a dark tile.

icon.ico now embeds 16/24/32/48/64/128/256 frames sourced from the
transparent tray master, fixing the dark-square halo around the
file/taskbar icon on light Windows themes.

__main__ picks icon-tray.png for the tray and falls back to
icon-192.png when the tray asset isn't present (older bundles /
forks).
2026-05-28 17:26:18 +03:00
alexei.dolgolyov 85da2e538d feat(backup): bundle assets in ZIP + partial-write hardening + restart log
Auto-backups now produce a ZIP containing ledgrab.db plus every file
in the assets dir under assets/ — matching the manual
GET /api/v1/system/backup format, so restore accepts either output
interchangeably. Legacy .db backups remain listable, restorable, and
prunable; both extensions count toward max_backups.

Writes stage to <name>.partial then os.replace into place — a crash
mid-ZIP never leaves a half-written backup that masquerades as valid.
Stale .partials from prior crashes are swept on the next run.
Symlinks inside the assets dir are skipped so a hostile link can't
slurp a target outside the dir into every backup. Backups larger than
500 MB log a warning so operators notice unbounded asset growth before
disk fills up.

restart.py: redirect the spawned restart script's stdout/stderr to
restart.log and bail out early if the script is missing — silent
failures (PowerShell off PATH, restart.ps1 erroring) used to vanish
into a detached child with no diagnostic trail.

Tests cover happy path, asset bytes round-trip, partial cleanup,
None/missing assets_dir, failure rollback, stale-partial sweep,
symlink rejection, mixed legacy+new listing, and cross-format prune.
2026-05-28 17:25:55 +03:00
alexei.dolgolyov e4d24a02da fix(ctypes): pin LPMSG across MSG-pump prototypes for Python 3.13
Python 3.13's ctypes tightened argtypes checks and now rejects
mismatched POINTER(MSG) cache entries — each call to POINTER(MSG)
can return a class identity that doesn't match what byref() of an
instance produces, raising "expected LP_MSG instance instead of
pointer to _MSG" inside GetMessageW/TranslateMessage/DispatchMessageW.

Capture POINTER(MSG) once into LPMSG and reuse the same class object
across all three prototypes in both the WindowsShutdownGuard pump and
the PlatformDetector display-power monitor. Restores the 4 failing
win_shutdown tests.
2026-05-28 17:25:37 +03:00
alexei.dolgolyov bb3a316e35 refactor(frontend): shared API client + automations registry (audit M7, H8)
H8 — automations.ts rule-type registry
  Convert the two hand-rolled RuleType dispatch ladders into per-type
  registries (RULE_FIELD_RENDERERS + RULE_COLLECTORS) keyed by RuleType,
  joining the existing RULE_CHIP_RENDERERS. All three are typed
  Record<RuleType, ...> for compile-time exhaustiveness; an import-time
  _assertRuleHandlerCoverage() check logs loudly if any registry drifts
  from RULE_TYPE_KEYS — mirrors the backend's _RULE_HANDLERS shape, the
  one intentional divergence being that the frontend logs rather than
  throws (a thrown error at module import would brick the whole bundle,
  not just the editor).

M7 — shared API client + 35 file migrations
  New core/api-client.ts wrapping fetchWithAuth with typed apiGet /
  apiPost / apiPut / apiPatch / apiDelete. Auth, 401-relogin, retry,
  timeout, and the offline toast all stay owned by fetchWithAuth; the
  client just collapses the
  if (!resp.ok) { detail || HTTP <status> } ... resp.json()
  dance into one typed call. The detail unwrap is hardened to join
  FastAPI validation arrays instead of stringifying to [object Object].

  35 feature/core files migrated to it across many batches, reviewer-
  approved for behaviour parity in three passes covering the riskier
  divergences (bulk Promise.allSettled deletes, inline-error saves,
  array-detail joins, silent-failure GETs, immutable clones).

  9 files remain on fetchWithAuth — the big god-modules tied to the
  pending C8/C9/C10 splits (streams, settings, targets, dashboard,
  color-strips/index, graph-editor, assets, value-sources) plus
  pairing-flow which by design stays on raw fetch (branches on raw
  Response.status codes).

i18n — 14 new locale keys (en / ru / zh)
  Added save/load/delete error keys across automations, pattern,
  audio_processing, audio_template, templates, gradient, target,
  device namespaces, plus backfilled gradient.error.delete_failed into
  ru/zh. Scan confirms no hardcoded English errorMessage strings
  remain in the migrated diff.

AUDIT_REMAINING.md updated to reflect H6, H8, and M7 status.

Verified: tsc --noEmit clean + npm run build clean after every batch.
2026-05-28 14:58:08 +03:00
alexei.dolgolyov 49c35a2ea0 refactor(frontend): split types.ts into 18 per-entity files (audit H6)
Convert the 1140-LOC types.ts into a pure re-export barrel backed by
focused per-entity files under types/, joining the existing
bindable.ts. Every import { ... } from '../types.ts' resolves
unchanged; reviewer-confirmed all 102 type exports preserved.
2026-05-28 14:57:25 +03:00
alexei.dolgolyov ef1f9eade2 feat(android): production-readiness pass — security, perf, compat, UI/UX
Multi-axis lift to ship-quality after a full review:

Security
- ApiKeyManager: per-install random API key, persisted via SharedPreferences
  with synchronous first-write; threaded into uvicorn via the
  LEDGRAB_AUTH__API_KEYS env var; embedded in QR as a URL fragment (#k=)
  so it never appears in HTTP requests or server logs; frontend reads
  location.hash on first visit and strips it via history.replaceState
- Root.runAsRoot(argv: Array<String>) overload with POSIX shell-quoting to
  eliminate the shell-injection footgun (= excluded from unquoted-safe set)
- UsbSerialBridge: ContextCompat.RECEIVER_NOT_EXPORTED + intent.package
  check in the broadcast receiver for defence-in-depth across API levels
- Release builds refuse to silently fall back to debug keystore; require
  ANDROID_KEYSTORE_* env vars or explicit
  ANDROID_ALLOW_DEBUG_SIGNED_RELEASE=1
- Crash log retention capped at 10 entries
- Fatal-error stack trace hidden behind a toggle on the error screen

Performance
- ScreenCapture / RootScreenrecord reuse a single RGBA ByteArray per
  pipeline instead of allocating per frame — eliminates ~15 MB/s GC churn
  at 30 fps on low-end TV boxes
- Frame pacer switched from System.currentTimeMillis() + integer division
  (~30.3 fps drift) to SystemClock.elapsedRealtimeNanos with a catch-up
  accumulator
- ScreenCapture computes capture dimensions from source aspect ratio so
  non-16:9 displays don't get squashed
- RootScreenrecord input pump backs off 5 ms when MediaCodec is starved,
  ending a tight spin that burned a CPU core on decoder stalls
- QR cached by URL — onResume from background no longer rebuilds the
  560×560 bitmap each time
- ApiKey commit() pre-warmed off Main on app startup

Compatibility
- compileSdk / targetSdk bumped to 35 (Play Store requirement)
- armeabi-v7a build path added to build script + conditionally included
  in gradle splits when the matching wheel is present in android/wheels/
- Foreground service type declared as mediaProjection|specialUse with
  PROPERTY_SPECIAL_USE_FGS_SUBTYPE rationale; promotion via
  ServiceCompat.startForeground with the correct type per mode
- NetworkUtils picks Ethernet > Wi-Fi > VPN > cellular instead of just
  activeNetwork — fixes wrong-URL on TV boxes with both Ethernet + Wi-Fi
- enableOnBackInvokedCallback=true for Android 15 predictive-back
- Splash screen API via androidx.core:core-splashscreen — hides Chaquopy
  stdlib unpack delay on cold first launch

UI / UX
- All previously hardcoded English strings (root prompt, permission
  denial, fatal-error screen, notification text) now localised across
  en/ru/zh
- Monochrome notification icon (was a colored launcher → gray blob in
  status bar)
- 320×180 TV banner (was the square launcher → squashed on Leanback row)
- ViewStub-based running panel (deferred inflation)
- ObjectAnimator pulse on the Running status dot for liveness feedback
- "Starting…" button state while root is being probed
- Autostart checkbox hidden entirely on unrooted devices
- "No network" status when getLocalIpAddress returns null
- QR fallback hint text
- Animator cancelled in onStop to avoid leaking view hierarchy

Lifecycle hardening (from review)
- RootScreenrecord: processLock serialises EOF respawn vs concurrent
  stop() to prevent orphaned screenrecord processes
- CaptureService.restartRootPipeline: publish-before-start under
  @Synchronized to close the orphan window during watchdog restarts
- ScreenCapture.MediaProjection.Callback.onStop just flips
  running=false instead of calling stop() (which self-joined
  captureThread and hung 500 ms)
- updateUI early-returns when lateinit not initialised (fatal-error path)
- Watchdog give-up bound fixed (>= instead of >, was allowing 4 attempts)

server/android_entry.py accepts an optional api_key, sets
LEDGRAB_AUTH__API_KEYS={"android":<key>} as JSON before any LedGrab
import, logs a clear error if pydantic-settings parsing doesn't land
the value back in config (defensive guard against future settings
behaviour drift).

server/static/js/app.ts: bootstrap reads #k= from location.hash,
persists to localStorage, then strips via history.replaceState.

Two independent code-review passes; 147 relevant server tests still
pass; TypeScript and ruff clean.
2026-05-26 12:52:14 +03:00
alexei.dolgolyov 8bdcc17799 chore: release v0.7.0
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 11s
Build Release / build-linux (push) Successful in 2m54s
Build Release / build-docker (push) Successful in 3m50s
Build Release / build-windows (push) Successful in 4m36s
2026-05-26 00:35:38 +03:00
alexei.dolgolyov f591e258f7 fix(storage/database): reopen connection on lifespan restart
Database opened its sqlite3 connection eagerly in __init__ and closed it
in close(); the lifespan called close() on shutdown. In production this
is fine — the lifespan runs once per process. Under pytest the module-
level ``db`` singleton survives across every TestClient session, so the
second test file's lifespan startup hit
``sqlite3.ProgrammingError: Cannot operate on a closed database`` at
fixture-setup time (AutoBackupEngine.__init__ → db.get_setting("…")
was the first reader). 65 spurious "errors" on a full Windows pytest run.

- Database: extract _open() from __init__, add ensure_open() that
  reopens iff _conn is None, and have close() null _conn after the
  TRUNCATE checkpoint so re-close is idempotent.
- main.py lifespan startup: call db.ensure_open() before any setting
  read, so subsequent TestClient sessions get a live connection.
- tests/storage/test_database_reopen.py: pin the four invariants —
  close→ensure_open round-trips data, ensure_open is a no-op when
  open, close is idempotent, and using the DB after close without
  ensure_open raises (callers must opt in).

Full backend suite: 1551 pass / 1 skip / 0 errors. Ruff clean.
2026-05-26 00:26:36 +03:00
alexei.dolgolyov f6486f9b34 perf(dashboard): diff FPS charts + cache spark SVG nodes; i18n perf strings
- dashboard: only destroy/recreate FPS charts for added/removed/detached
  targets; skip the history fetch when local samples already exist.
  Drops sync-clock `is_running` from the structure signature so toggles
  don't trigger a full rebuild; route clock/automation refresh through
  the in-place path.
- perf-charts: cache SVG skeleton per spark host and mutate node
  attributes instead of rewriting `innerHTML` every poll. Memoize
  patches/devices rendering by content signature so unchanged ticks
  no longer restart CSS animations. Skip render for env-hidden cards.
- perf-charts: switch `/system/performance` poll to `fetchWithAuth`,
  re-read `dashboardPollInterval` per tooltip move, and route the
  remaining hardcoded English strings ("no captures", "{n} total",
  "{rate} skipped/s", tooltip age, metric labels) through `t()`.
- locales: add `perf.no_captures`, `perf.captures_count`,
  `perf.ratio_of_requested`, `perf.total_count`, `perf.skipped_per_sec`,
  `perf.tip.now`, `perf.tip.ago` in en/ru/zh.
2026-05-26 00:12:29 +03:00
alexei.dolgolyov 48dbdb90e9 docs(review-todo): check off items addressed in 2026-05-23 autonomous pass
Mark devices.py PATCH fix, WLED route-level test, IPv6 regression
test, IconSelect XSS audit, PEP-604 sweep, magic-number constants,
api/auth except specificity, and the (window as any) static-access
cleanup as done. Defer items are unchanged: performance items keep
their "profile first" caveat, Hue cert pinning + CSP keep the design-
sensitive note, architecture refactors keep the multi-day banner,
and i18n parity is now annotated with the exact missing-key counts
(328 ru / 325 zh) so the next translator pass has a clear scope.
2026-05-23 01:22:41 +03:00
alexei.dolgolyov 003517247f refactor(types): migrate (window as any) statics to typed window globals
59 sites across 19 feature modules switched from
`(window as any).foo` to the typed `window.foo` form against
global-types.d.ts. The 7 remaining sites use dynamic string indexing
(`window[fnName]`) where a typed access is impossible — those keep
the narrow cast and are documented as the legitimate exception in
the typedef file's header.

global-types.d.ts grows entries for `loadIntegrations`,
`loadUpdateSettings`, `loadUpdateStatus`, `initUpdateSettingsPanel`,
`onTestDisplaySelected`, `openSettingsModal`, `renderAboutPanel`,
`switchSettingsTab`. The `applyAccentColor` signature is widened to
accept the (accent, persist) call shape observed at the appearance
preset call site so tsc validates the real contract.
2026-05-23 01:22:29 +03:00
alexei.dolgolyov 888f8fd16e refactor(types): PEP-604 union sweep + UP007/UP045 enforcement
ruff --select UP007,UP045 --fix converted ~1760 sites across the
backend: `Optional[T]` → `T | None`, `Union[X, Y]` → `X | Y`. The
remaining module-level alias targets that ruff conservatively skips
(BindableFloatInput, ColorList, DeviceConfig) were converted by hand
earlier in the pass. black -formatted the result so the wider unions
fit cleanly under the 100-char line budget.

pyproject.toml now sets [tool.ruff.lint] extend-select = ["UP007",
"UP045"] so future legacy imports fire CI on every push. The
pre-commit ruff hook was bumped from v0.8.0 -> v0.15.12 to recognise
UP045 (split off from UP007 in v0.13).
2026-05-23 01:21:44 +03:00
alexei.dolgolyov ea7ee88490 refactor(api/auth): narrow WS exception catches + observability log
The 11 except Exception sites around websocket.send_json and
websocket.close are now except _WS_SEND_BENIGN_EXC — a narrow tuple of
WebSocketDisconnect, RuntimeError, ConnectionError, OSError. Real
programming errors (AttributeError, TypeError) no longer silently
disappear inside the handshake path. The receive_text branch grows a
narrow `(RuntimeError, ConnectionError, OSError)` case plus a final
`except Exception: logger.exception(...)` catch-all so genuinely
unexpected error shapes are recorded with a stack trace instead of
being swallowed.
2026-05-23 01:14:43 +03:00
alexei.dolgolyov d38021f061 refactor(processing): hot-path magic numbers -> named module constants
processed_stream lifts the 30-iteration filter recheck cadence to
_FILTER_RECHECK_EVERY_N_FRAMES with a comment explaining the 30 fps
trade-off. wled_target_processor lifts SKIP_REPOLL, the diagnostics
interval, and the CSPT recheck cadence to module-level
_SKIP_REPOLL_SLEEP_SECONDS, _DIAGNOSTICS_REPORT_INTERVAL_SECONDS, and
_CSPT_RECHECK_EVERY_N_ITERATIONS. Tests can monkeypatch them now.
2026-05-23 01:14:31 +03:00
alexei.dolgolyov 507e1385a6 feat(ui/icon-select): defence-in-depth XSS sanitiser on icon channel
Every IconSelect caller was audited: each builds item.icon from a
constant ICON_* literal, a lookup-table getter, or
renderDeviceIcon(stored_id) — none of which embed user input today.
The new sanitiseIcon() helper is the belt-and-braces guard for a
future caller that forgets the trusted-SVG contract: reject icon
strings containing <script>/<iframe>/<embed>/<object>/javascript:/on*=
markers, warn to the console, and fall back to empty so the cell still
renders the (escaped) label + desc.
2026-05-23 01:13:55 +03:00
alexei.dolgolyov 907bdaf043 test(url-scheme): WLED route-level integration + IPv6 regression
TestWLEDSchemeInference in test_devices_routes covers the POST/PUT
create-and-update flow with a stubbed WLED provider so the
infer_http_scheme integration hop has end-to-end coverage instead of
just the unit tests.

test_url_scheme grows public IPv6 (Cloudflare / Google / Quad9 DNS),
bracketed-form, and ULA cases. Adds an explicit pin for the Python
ipaddress documentation-prefix quirk (2001:db8::/32 is is_private,
so it routes to http:// even though some audits colloquially call it
"public").
2026-05-23 01:13:44 +03:00
alexei.dolgolyov 0dd8d430b9 fix(devices): preserve existing URL on PATCH-without-url
When a PATCH omits `url` (rename / icon-only edit), normalized_url
arrived at the processor as None and the manager kept whatever it had
cached — or refused to re-sync if it had nothing. Fall back to
existing.url so the processor is always told the current address.
Surfaced by the production-review backlog.
2026-05-23 01:13:13 +03:00
377 changed files with 10680 additions and 5396 deletions
+26 -1
View File
@@ -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,25 @@ jobs:
if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
docker push "$REGISTRY: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"
+4 -1
View File
@@ -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]
+92 -11
View File
@@ -18,6 +18,7 @@ context.
| `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
@@ -100,16 +101,35 @@ registry.
**Estimated scope:** 1-2 sessions; coupled to H4.
#### H8 — `automations.ts` 1410 LOC
#### 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` is still
backend in `98fb61d`; the frontend dispatch on `RuleType` was
hand-rolled.
**Approach:** introduce a rule-type registry on the frontend matching
the backend's `_RULE_HANDLERS` shape.
**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`.)
**Estimated scope:** half a session.
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
@@ -161,16 +181,66 @@ 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
#### 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. ~25 files.
feature's save / load function. ~45 files, ~243 call sites.
**Approach:** introduce `static/js/core/api-client.ts` with typed
methods (`get`, `post`, `put`, `delete`) that handle auth, JSON parsing,
error normalisation. Replace `fetchWithAuth` calls across features.
**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
@@ -262,7 +332,11 @@ always start before reading).
### Other frontend (severity in main list above)
- **H6 rest** — split remaining ~1100 LOC of `types.ts` into per-entity files
- **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
@@ -299,6 +373,13 @@ 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
+34 -23
View File
@@ -1,43 +1,54 @@
## v0.6.1 (2026-05-10)
## v0.8.1 (2026-05-28)
### Features
### User-facing changes
- Per-surface card presentation modes (C/M/D/R) for the UI ([75ca487](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/75ca487))
- Customisable card icon for all entity types ([0f5850e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0f5850e))
- HA-Light: broadcast a single Color Value Source to all entities ([a79f4bf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a79f4bf))
- Targets: customisable card icon plus HA-light stop action ([ced72fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ced72fc))
- Customisable card icon plate for devices ([49ddabb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49ddabb))
#### Features
### Bug Fixes
##### Multi-broker MQTT devices
- Shutdown: apply target stop actions before tearing down HA/MQTT so devices end up in their configured state ([6a07a6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6a07a6b))
- The device editor now shows an MQTT **broker picker** for `device_type=mqtt` (in both the add-device and device-settings modals), wired into load / save / validate / dirty-check / clone. An empty selection means "first available broker"
- `mqtt_source_id` is now threaded end-to-end through `DeviceCreate` / `DeviceUpdate` / `DeviceResponse` and the device routes; the referenced broker is validated on create **and** update ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
##### Schema-driven wiring-graph editor
- The visual graph editor now renders ports and edges generically from a backend-served schema (`GET /api/v1/graph/schema`) instead of hard-coding the connectable-field topology in two places — so client and server can no longer drift
- New `GET /api/v1/graph` returns the full nodes + edges + validation topology, and `GET /api/v1/graph/dependents/{kind}/{id}` reports what references an entity ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
##### Aggregated snapshot endpoint
- New `GET /api/v1/snapshot` returns all output targets (with processing state + metrics), devices (with brightness), the source / preset / clock lists, and the system block in a **single response** — collapsing the Home Assistant integration's previous ~2N+M request fan-out into one round trip
- `?include=` fetches only a subset of sections, and an excluded section also skips its server-side work (e.g. cold-cache hardware brightness probes or the blocking NVML performance query) ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
#### Bug Fixes
- **Graceful shutdown no longer hangs:** uvicorn's graceful-shutdown wait is now bounded (`GRACEFUL_SHUTDOWN_TIMEOUT`, shared by the desktop, Android, and demo launchers). A lingering events WebSocket (which the browser auto-reconnects) used to keep connections from draining, so the lifespan shutdown never ran — leaving LED targets lit and blocking process exit. Ctrl+C / OS shutdown with the UI open now reliably stops targets and checkpoints the DB ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
- **Device update error codes:** `update_device` no longer masks an intentional 4xx (e.g. an unknown `mqtt_source_id` or failed group validation) as a generic 500 ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
---
### Development / Internal
#### CI/Build
#### Backend
- Android: fail-fast on missing release keystore before SDK setup ([a026f0b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a026f0b))
- **Wiring-graph schema engine** (`api/graph_schema.py`): a pure, unit-tested module that is the single source of truth for which reference fields connect which entity kinds; builds the topology and performs dependency lookup plus cycle / dangling-reference detection without booting the app or any store. The route layer only gathers serialized entities and delegates ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
- **Structured access log:** a new middleware emits one structured line per request, attributing it to the authenticated token's friendly label (the key name, **never** the secret) so traffic can be traced to a client (e.g. `homeassistant` vs `android`). uvicorn's own access log is disabled to avoid duplicate lines ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
- Shared `validate_mqtt_source_exists` (`_mqtt_validation.py`) deduplicates the MQTT-source existence check between the device and output-target routes ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
#### Chores
#### Frontend
- Clean up `cfg` abbreviation and stale TODO link ([e65dcb4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e65dcb4))
- Service-worker refresh for the new bundle ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
#### Tests
- New suites: graph routes + schema engine, snapshot routes, access-log middleware, `mqtt_source_id` device regressions, and the bounded-shutdown entrypoint. Full suite: **1614 passing** ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
---
<details>
<summary>All Commits</summary>
<summary>All Commits (1)</summary>
| Hash | Message | Author |
|------|---------|--------|
| [75ca487](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/75ca487) | feat(ui): per-surface card presentation modes (C/M/D/R) | alexei.dolgolyov |
| [e65dcb4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e65dcb4) | chore: clean up cfg abbreviation and stale TODO link | alexei.dolgolyov |
| [6a07a6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6a07a6b) | fix(shutdown): apply target stop actions before tearing down HA/MQTT | alexei.dolgolyov |
| [0f5850e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0f5850e) | feat(ui): customisable card icon for all entity types | alexei.dolgolyov |
| [a79f4bf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a79f4bf) | feat(ha-light): broadcast a single Color Value Source to all entities | alexei.dolgolyov |
| [ced72fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ced72fc) | feat(targets): customisable card icon + HA-light stop action | alexei.dolgolyov |
| [49ddabb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49ddabb) | feat(ui): customisable card icon plate for devices | alexei.dolgolyov |
| [a026f0b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a026f0b) | ci(android): fail-fast on missing release keystore before SDK setup | alexei.dolgolyov |
| ---- | ------- | ------ |
| [a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba) | feat: aggregated snapshot + wiring-graph APIs, MQTT device brokers | alexei.dolgolyov |
</details>
+249
View File
@@ -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. ~150300 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.
+66 -48
View File
@@ -18,6 +18,40 @@ 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)
@@ -92,12 +126,8 @@ typed `Window` globals, and more).
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.
- [ ] **`api/auth.py` exception specificity** — 9 `except Exception:`
sites. Most are intentional best-effort `websocket.send_json`
swallows (the WS is already closed or about to be), but the auth
decision path itself could be tightened to specific types
(`jwt.InvalidTokenError`, `OSError`) + `logger.exception` for
observability.
- [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
@@ -105,58 +135,46 @@ typed `Window` globals, and more).
## Mechanical / code-quality (low risk, high line-count)
- [ ] **i18n parity****328** keys missing in `ru.json`, **325** missing
in `zh.json`. Examples: `section.hide`, `filters.hsl_shift`,
`filters.contrast`, `filters.temporal_blur`,
`filters.audio_filter_template.desc`. Russian and Chinese users
currently see raw keys for these. This is translation work, not
code work.
- [ ] **`Optional[T]``T | None`** (PEP 604) — large mechanical refactor
across the codebase. Can be auto-fixed via `ruff check --fix
--select UP007`. Worth doing once the file splits land.
- [ ] **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 — mostly cosmetic; ~200 sites. The f-string still builds
the message even when DEBUG is off.
- [ ] **Remaining `(window as any)` sites** — typed `global-types.d.ts`
is in place and new code uses `window.foo` directly, but ~80
existing sites still have the cast. Per-site mechanical cleanup.
Add `eslint`-equivalent guard (TS rule) to prevent new ones.
- [ ] **Magic numbers → named constants** in processing hot paths —
`_FILTER_RECHECK_EVERY_N_FRAMES = 30` in
`core/processing/processed_stream.py:159`; `5 ms` / `5 s` /
`30 iterations` literals in `wled_target_processor.py:890,893,915`.
- [ ] **Standardise `from __future__ import annotations`** across the
codebase. Some modules use the future-annotation form, others stick
with `Optional[...]`. Enforce one via ruff `FA` rules.
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
- [ ] **Route-level integration test** for the WLED scheme inference —
POST `/api/v1/devices` with `{"url": "192.168.1.42",
"device_type": "wled"}` and assert the stored device has
`url == "http://192.168.1.42"`. The helper is exhaustively
unit-tested but no integration test exercises the create/update
flow end-to-end.
- [ ] **IPv6 public address regression** — extend `test_url_scheme.py`
with explicit assertions for `2001:db8::1` and similar public IPv6
literals (the bare-label fallback used to misclassify these). The
helper does the right thing today via the IPv6 probe added during
the hardening pass, but no test pins it.
- [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:
- [ ] **`icon-select.ts:_buildGrid` `item.icon` is interpolated raw** —
documented as "trusted SVG by design". If callers ever feed
user-supplied icon strings, that's an XSS sink. Audit every caller
that builds `IconSelectItem.icon` from non-constant data and
reject HTML there.
- [ ] **`devices.py:461` `manager.update_device_info(device_url=update_data.url)`**
receives `None` when a PATCH omits `url` (rename / icon-only edit).
The processor never re-syncs in that case. Should pass
`existing.url` (after normalization) or skip the call.
- [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.
+18 -5
View File
@@ -201,9 +201,19 @@ caller off the legacy path, then delete it.
- [x] Field on `device_config.MQTTConfig`
- [x] `MQTTLEDClient` acquires runtime in `connect()`, releases in `close()`
- [x] Provider threads `mqtt_manager` via `ProviderDeps`
- [ ] Device editor: MQTT source picker shown for `device_type=mqtt` *(UI still
pending — backend accepts the field, but the device-create form doesn't
expose it yet)*
- [x] Device editor: MQTT source picker shown for `device_type=mqtt`. Turned
out the API layer was *also* missing it (the TODO's "backend accepts the
field" was wrong — `mqtt_source_id` lived in `device_store` +
`device_config.MQTTConfig` but was dropped by `DeviceCreate/Update/Response`
and the routes). Added: schema fields + route threading + referenced-source
validation (`_validate_mqtt_source_exists`, mirrors output_targets) +
`except HTTPException: raise` guard in `update_device` (it was masking its
own 4xx as 500). Frontend: broker `EntitySelect` (reusing `mqttSourcesCache`)
in both the add-device (`device-discovery.ts`) and settings
(`devices.ts`) modals — shown for `device_type=mqtt`, wired into
load/save/validate/dirty-check/clone. Empty = "first available broker".
4 regression tests in `test_devices_routes.py::TestMqttSourceId`; full
suite 1567 passing; en/ru/zh keys added.
### Phase 5 — `AutomationEngine`
@@ -213,8 +223,11 @@ caller off the legacy path, then delete it.
### Phase 6 — `api/routes/system.py`
- [x] Replace integration status with `mqtt_manager.get_all_sources_status()`
- [ ] Update frontend dashboard payload (MQTT widget now expects a list of
sources instead of a single `enabled`/`connected` pair — surface in UI)
- [x] Update frontend dashboard payload (MQTT widget now expects a list of
sources instead of a single `enabled`/`connected` pair — surface in UI).
Done: `dashboard.ts` `_renderMQTTIntegrationCard` renders one card per
`mqttStatus.connections` entry; `_updateIntegrationsInPlace` iterates the
list.
### Phase 7 — Startup migration
+44 -11
View File
@@ -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.6.1"
versionName = "0.8.1"
// 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,6 +205,9 @@ 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")
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
+27 -7
View File
@@ -26,9 +26,15 @@
<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" />
<!-- POST_NOTIFICATIONS for Android 13+ foreground service notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -60,27 +66,41 @@
<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"
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>
<!-- Autostart — fires on device boot (and package replace).
On rooted devices, launches CaptureService directly so capture
@@ -0,0 +1,98 @@
package com.ledgrab.android
import android.content.Context
import android.util.Log
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 prefs = context.applicationContext
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
// 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
}
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
}
}
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 KEY_API_KEY = "api_key"
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"
}
}
@@ -7,6 +7,7 @@ import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Build
@@ -15,6 +16,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 +28,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() {
@@ -92,15 +100,33 @@ 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: startForeground must be called IMMEDIATELY — before
// any other work, especially before getMediaProjection(). The
// service type must match the work; pass it explicitly via
// ServiceCompat so we stay compatible back to API 24.
val localIp = NetworkUtils.getLocalIpAddress(this) ?: ""
val url = "http://$localIp:$SERVER_PORT"
try {
startForeground(NOTIFICATION_ID, buildNotification(url))
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
if (useRoot) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
}
} 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,8 +135,6 @@ 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.
@@ -140,10 +164,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 +194,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
rootCapture = null
runCatching { old?.stop() }
// Tear down the old instance first so we don't run two
// screenrecord processes simultaneously fighting for the GPU.
rootCapture?.let { old ->
rootCapture = null
runCatching { old.stop() }
}
val next = RootScreenrecord(
bridge = currentBridge,
@@ -180,11 +216,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 +258,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
@@ -263,7 +309,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 +321,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
@@ -323,10 +368,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 +388,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()
@@ -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,15 @@ class LedGrabApp : Application() {
// Bind application context for the BLE bridge so Python can
// scan and connect to BLE LED controllers.
BleBridge.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 +90,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
}
}
@@ -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,16 @@ 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.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.CoroutineScope
@@ -46,25 +53,47 @@ 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 QR_SIZE_PX = 560
}
// 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
// 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 +101,62 @@ 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)
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)
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()
// Autostart only takes effect on rooted devices. Hide the
// checkbox entirely on unrooted hardware instead of showing a
// disabled-but-visible control, which reads as broken UI from
// across the room.
if (Root.looksRooted()) {
autostartCheck.visibility = View.VISIBLE
autostartCheck.isChecked = autostartPrefs.isEnabled
autostartCheck.setOnCheckedChangeListener { _, isChecked ->
autostartPrefs.isEnabled = isChecked
if (isChecked) ensureIgnoringBatteryOptimizations()
}
} else {
autostartCheck.visibility = View.GONE
}
toggleButton.setOnClickListener { startCapture() }
stopButtonRunning.setOnClickListener { stopCaptureService() }
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()
// 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.
if (CaptureService.isRunning && ::stoppedPanel.isInitialized) {
updateUI()
}
}
/**
* Decide whether to go through the MediaProjection consent flow or
* jump straight into root capture. Root check is fast but may block
@@ -112,20 +164,19 @@ 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()
withContext(Dispatchers.Main) {
toggleButton.isEnabled = true
toggleButton.text = originalText
statusText.text = ""
if (rooted) {
Log.i(TAG, "Root available — skipping MediaProjection consent")
@@ -156,7 +207,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")
}
}
@@ -174,42 +225,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)
// Build the bitmap pixels off the Main thread — encode + 313k
// setPixel calls were noticeably janky on slow TV boxes.
uiScope.launch(Dispatchers.Default) {
val bitmap = generateQrCode(url)
withContext(Dispatchers.Main) {
if (CaptureService.isRunning && urlText.text == url) {
qrImage.setImageBitmap(bitmap)
private fun updateUI() {
// Fatal-init-error path took over setContentView and the
// lateinit view fields are unassigned. Guard so any future
// caller (Resume, broadcast receiver, etc.) doesn't NPE.
if (!::stoppedPanel.isInitialized) return
if (CaptureService.isRunning) {
val running = ensureRunningPanelInflated()
val localIp = NetworkUtils.getLocalIpAddress(this)
if (localIp == null) {
// No network — show the no-network state inside the
// stopped panel and keep capture stopped. The service
// is alive (capture works on loopback) but the URL/QR
// are useless without a routable address.
statusText.text = getString(R.string.status_no_network)
stoppedPanel.visibility = View.VISIBLE
versionText.visibility = View.VISIBLE
running.visibility = View.GONE
toggleButton.requestFocus()
return
}
val displayUrl = "http://$localIp:$SERVER_PORT"
val qrUrl = qrUrlFor(displayUrl)
urlText?.text = displayUrl
val cachedForUrl = cachedQrBitmap?.takeIf { cachedQrUrl == qrUrl }
if (cachedForUrl != null) {
qrImage?.setImageBitmap(cachedForUrl)
} else {
qrImage?.setImageBitmap(null)
// Build the bitmap pixels off the Main thread — encode + 313k
// setPixel calls were noticeably janky on slow TV boxes.
uiScope.launch(Dispatchers.Default) {
val bitmap = generateQrCode(qrUrl)
withContext(Dispatchers.Main) {
if (CaptureService.isRunning && urlText?.text == displayUrl) {
cachedQrUrl = qrUrl
cachedQrBitmap = bitmap
qrImage?.setImageBitmap(bitmap)
}
}
}
}
stoppedPanel.visibility = View.GONE
versionText.visibility = View.GONE
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 +357,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 +413,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
@@ -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) {
@@ -55,9 +56,14 @@ class PythonBridge(private val context: Context) {
/**
* 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 +77,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")
entry.callAttr("start_server", dataDir, port)
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 +116,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
@@ -100,14 +100,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 +149,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()
@@ -84,11 +106,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 +129,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 +144,13 @@ class RootScreenrecord(
runCatching { readerThread?.join(500) }
readerThread = null
runCatching { process?.destroy() }
process = 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 +160,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 +168,17 @@ 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) }
val rowBytes = width * pixelStride
val expected = rowBytes * height
if (rowStride == rowBytes && buffer.remaining() >= expected) {
buffer.get(frameBuffer, 0, expected)
} else {
// Strip row padding — common when width isn't a multiple of 16.
val rowBytes = width * pixelStride
ByteArray(width * height * 4).also { out ->
for (row in 0 until height) {
buffer.position(row * rowStride)
buffer.get(out, row * rowBytes, rowBytes)
}
for (row in 0 until height) {
buffer.position(row * rowStride)
buffer.get(frameBuffer, row * rowBytes, rowBytes)
}
}
bridge.pushRootFrame(bytes, width, height)
bridge.pushRootFrame(frameBuffer, width, height)
framesDeliveredCounter.incrementAndGet()
} catch (e: Exception) {
Log.w(TAG, "Root frame delivery failed: ${e.message}")
@@ -173,18 +200,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 +245,56 @@ class RootScreenrecord(
// exits cleanly we respawn so capture survives
// long sessions instead of freezing after ~3min.
Log.i(TAG, "screenrecord EOF — respawning")
runCatching { process?.destroy() }
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
}
process = next
// 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,30 @@ 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
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 +165,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 +174,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>
+27 -115
View File
@@ -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,8 @@
android:text="@string/btn_start"
android:textSize="22sp"
android:focusable="true"
android:focusableInTouchMode="true" />
android:focusableInTouchMode="true"
android:nextFocusDown="@+id/autostart_check" />
<CheckBox
android:id="@+id/autostart_check"
@@ -63,10 +76,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 +91,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,26 @@
<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>
</resources>
@@ -3,12 +3,26 @@
<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>
</resources>
@@ -3,12 +3,26 @@
<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>
</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" />
+17 -12
View File
@@ -1,16 +1,18 @@
#!/usr/bin/env bash
#
# Cross-compile pydantic-core for Android across all three ABIs:
# arm64-v8a (primary — real TV hardware)
# x86_64 (modern emulators)
# x86 (legacy emulators)
# 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 arm64 # build a single ABI
# ./build-pydantic-core.sh arm64 x86_64 # build a subset
# ./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
@@ -91,21 +94,23 @@ fi
# ── ABI table ───────────────────────────────────────────────────────
# Columns: short_name rust_target clang_prefix sysconfig_dir
ABI_TABLE=(
"arm64 aarch64-linux-android aarch64-linux-android${API_LEVEL} cross-sysconfig"
"x86_64 x86_64-linux-android x86_64-linux-android${API_LEVEL} cross-sysconfig-x86_64"
"x86 i686-linux-android i686-linux-android${API_LEVEL} cross-sysconfig-x86"
"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 ───────────────────────────────
+327
View File
@@ -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 (01)
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 1632 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()
+9 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ledgrab"
version = "0.6.1"
version = "0.8.1"
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"]
+1 -1
View File
@@ -9,7 +9,7 @@ from pathlib import Path
# 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.4.2"
_FALLBACK_VERSION = "0.8.1"
def _read_pyproject_version() -> str | None:
+26 -10
View File
@@ -39,8 +39,9 @@ _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
@@ -49,7 +50,8 @@ 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:
@@ -107,17 +109,28 @@ 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
@@ -154,8 +167,9 @@ 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),
)
@@ -163,9 +177,11 @@ def main() -> None:
tray.run()
# Tray exited — wait for server to finish its graceful shutdown.
# Use a longer join than the lifespan's own ~18 s budget so we don't
# cut the DB checkpoint short on a slow disk.
server_thread.join(timeout=20)
# 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:
+39 -5
View File
@@ -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
+4
View File
@@ -33,6 +33,8 @@ from .routes.audio_processing_templates import router as audio_processing_templa
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
router = APIRouter()
router.include_router(system_router)
@@ -66,5 +68,7 @@ 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)
__all__ = ["router"]
+37 -11
View File
@@ -19,6 +19,19 @@ logger = get_logger(__name__)
security = HTTPBearer(auto_error=False)
# 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:
"""Return True when at least one API key is configured."""
return bool(get_config().auth.api_keys)
@@ -67,6 +80,7 @@ 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"
return "anonymous"
# Allow caller to authenticate explicitly even without configured keys?
# No — there are no keys to compare against. Reject.
@@ -110,6 +124,9 @@ 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
return authenticated_as
@@ -181,7 +198,7 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
)
try:
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
except Exception:
except _WS_SEND_BENIGN_EXC:
pass
return None
@@ -190,7 +207,7 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
if label is None:
try:
await websocket.close(code=WS_AUTH_CLOSE_CODE)
except Exception:
except _WS_SEND_BENIGN_EXC:
pass
return None
return label
@@ -254,20 +271,29 @@ 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)
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:
@@ -277,7 +303,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
@@ -286,7 +312,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
@@ -296,7 +322,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
@@ -313,7 +339,7 @@ async def verify_ws_auth(
"reason": "LAN access requires an API key",
}
)
except Exception:
except _WS_SEND_BENIGN_EXC:
pass
return None
@@ -323,13 +349,13 @@ async def verify_ws_auth(
logger.warning("Invalid WebSocket auth attempt from %s", 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
+501
View File
@@ -0,0 +1,501 @@
"""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"),
# ── 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) ──
*(
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", "picture_source_id", "picture_source", "picture"),
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]
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,
}
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 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 {}
# ── 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 cf.is_list:
# List slots (layers/zones/scene targets) hold many edges sharing the
# same (to, field); without an element index this endpoint can't model
# which one is being replaced for the cycle check. Edit those via the
# entity editor.
return False, f"List connection '{field}' must be edited via the entity editor"
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.
@@ -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
@@ -91,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),
+48 -16
View File
@@ -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
@@ -38,28 +39,59 @@ _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()
+50 -2
View File
@@ -13,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,
)
@@ -33,10 +34,13 @@ from ledgrab.api.schemas.devices import (
)
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()
@@ -105,6 +109,7 @@ def _device_to_response(device) -> DeviceResponse:
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,
@@ -124,11 +129,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":
@@ -287,6 +294,7 @@ async def create_device(
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,
)
@@ -543,12 +551,14 @@ 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
@@ -634,17 +644,25 @@ async def update_device(
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=normalized_url,
device_url=effective_url,
led_count=normalized_led_count,
baud_rate=update_data.baud_rate,
)
@@ -662,6 +680,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:
@@ -770,6 +792,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,
+124
View File
@@ -0,0 +1,124 @@
"""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,
find_dependents,
schema_as_dicts,
serialize_entity,
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(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}
@@ -1,7 +1,7 @@
"""Output target routes: CRUD endpoints and batch state/metrics queries."""
import asyncio
from typing import Annotated, Optional
from typing import Annotated
from fastapi import APIRouter, Body, HTTPException, Depends
@@ -49,6 +49,8 @@ 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()
@@ -270,16 +272,6 @@ def _validate_device_exists(device_store: DeviceStore, device_id: str) -> None:
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
def _validate_mqtt_source_exists(mqtt_store: MQTTSourceStore, mqtt_source_id: str) -> None:
"""Ensure the referenced MQTT source exists. Empty id is allowed (unconfigured)."""
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")
@router.post(
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
)
@@ -333,7 +325,7 @@ async def create_target(
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)
validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
target = target_store.create_z2m_light_target(
name=data.name,
description=data.description,
@@ -421,7 +413,7 @@ async def get_target(
def _resolve_effective_color_vs_id(
target_store: OutputTargetStore, target_id: str, payload_id: Optional[str]
target_store: OutputTargetStore, target_id: str, payload_id: str | None
) -> str:
if payload_id is not None:
return payload_id
@@ -540,7 +532,7 @@ async def update_target(
)
_validate_color_value_source(value_source_store, effective_id)
if data.mqtt_source_id:
_validate_mqtt_source_exists(mqtt_store, 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,
+201
View File
@@ -0,0 +1,201 @@
"""Aggregated snapshot endpoint for low-overhead polling clients.
Returns, in a single response, everything the Home Assistant integration's
coordinator needs per poll: all output targets with processing state + metrics,
all devices with brightness, the color-strip / value-source / scene-preset /
sync-clock lists, and the system block (performance, health, update).
This collapses the integration's previous ~2N+M request fan-out (per-target
``/state`` + ``/metrics`` and per-device ``/brightness``) into one round trip.
The handler delegates to the existing list/batch route handlers so the response
sub-shapes stay byte-identical to the individual endpoints — no shaping logic is
duplicated here.
Callers that don't need the whole payload can pass ``?include=`` with a
comma-separated subset of section names (the response keys). Omitting it returns
every section. Gating is per section, so an excluded section also skips its
server-side work — dropping ``device_brightness`` avoids cold-cache hardware
probes, and dropping ``system`` skips the (blocking) NVML performance query.
"""
import asyncio
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.concurrency import run_in_threadpool
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
get_color_strip_store,
get_device_store,
get_output_target_store,
get_processor_manager,
get_scene_preset_store,
get_sync_clock_manager,
get_sync_clock_store,
get_update_service,
get_value_source_store,
)
from ledgrab.api.schemas.update import UpdateStatusResponse
from ledgrab.utils import get_logger
from .color_strip_sources.crud import list_color_strip_sources
from .devices import list_devices, resolve_device_brightness
from .output_targets import batch_target_metrics, batch_target_states, list_targets
from .scene_presets import list_scene_presets
from .sync_clocks import list_sync_clocks
from .system import get_system_performance, health_check
from .update import get_update_status
from .value_sources import list_value_sources
logger = get_logger(__name__)
router = APIRouter()
# Selectable snapshot sections — these are exactly the response top-level keys.
SNAPSHOT_SECTIONS = (
"targets",
"target_states",
"target_metrics",
"devices",
"device_brightness",
"css_sources",
"value_sources",
"scene_presets",
"sync_clocks",
"system",
)
_SECTION_SET = frozenset(SNAPSHOT_SECTIONS)
def _resolve_sections(include: str | None) -> frozenset[str]:
"""Validate the ``include`` query param into the set of sections to emit.
``None``/empty → every section. Unknown names are rejected with 422 so a
typo fails loudly instead of silently returning a smaller payload.
"""
if not include:
return _SECTION_SET
requested = {part.strip() for part in include.split(",") if part.strip()}
unknown = requested - _SECTION_SET
if unknown:
raise HTTPException(
status_code=422,
detail=(
f"Unknown snapshot section(s): {', '.join(sorted(unknown))}. "
f"Valid sections: {', '.join(SNAPSHOT_SECTIONS)}."
),
)
return frozenset(requested)
async def _safe_section(awaitable, label: str):
"""Await a section, degrading to ``None`` on failure instead of 500-ing.
The snapshot is a resilience-oriented poll surface: one failing section
(e.g. NVML performance probing) must not fail the whole response. This
preserves the per-section fault isolation the HA coordinator relied on
before these calls were merged into one request — the coordinator already
tolerates a ``None`` section.
"""
try:
return await awaitable
except Exception:
logger.warning("snapshot: section %r failed, returning null", label, exc_info=True)
return None
async def _update_status_model(_auth, update_service) -> UpdateStatusResponse:
"""Fetch update status and coerce it through the response model.
The standalone ``/system/update/status`` endpoint declares
``response_model=UpdateStatusResponse``; coercing here keeps the snapshot's
``system.update`` field identical to that endpoint rather than emitting the
service's raw dict unfiltered.
"""
raw = await get_update_status(_auth, update_service)
return UpdateStatusResponse.model_validate(raw)
@router.get("/api/v1/snapshot", tags=["Snapshot"])
async def get_snapshot(
request: Request,
_auth: AuthRequired,
include: str | None = Query(
None,
description=(
"Comma-separated subset of sections to include. Omit for all. "
"Valid: " + ", ".join(SNAPSHOT_SECTIONS)
),
),
manager=Depends(get_processor_manager),
target_store=Depends(get_output_target_store),
device_store=Depends(get_device_store),
css_store=Depends(get_color_strip_store),
value_store=Depends(get_value_source_store),
preset_store=Depends(get_scene_preset_store),
clock_store=Depends(get_sync_clock_store),
clock_manager=Depends(get_sync_clock_manager),
update_service=Depends(get_update_service),
) -> dict[str, Any]:
"""Return the full poll payload (or a requested subset) in one response.
Shape (a key is present only when its section is requested)::
{
"targets": [<OutputTargetResponse>, ...],
"target_states": {target_id: <state>, ...},
"target_metrics": {target_id: <metrics>, ...},
"devices": [<DeviceResponse>, ...],
"device_brightness": {device_id: int | null, ...},
"css_sources": [...],
"value_sources": [...],
"scene_presets": [...],
"sync_clocks": [...],
"system": {"performance": {...}, "health": {...}, "update": {...}}
}
"""
sections = _resolve_sections(include)
result: dict[str, Any] = {}
if "targets" in sections:
result["targets"] = (await list_targets(_auth, target_store)).targets
if "target_states" in sections:
result["target_states"] = (await batch_target_states(_auth, manager))["states"]
if "target_metrics" in sections:
result["target_metrics"] = (await batch_target_metrics(_auth, manager))["metrics"]
if "devices" in sections:
result["devices"] = (await list_devices(_auth, device_store)).devices
if "device_brightness" in sections:
device_models = device_store.get_all_devices()
brightness_values = await asyncio.gather(
*(resolve_device_brightness(d, manager) for d in device_models),
return_exceptions=True,
)
result["device_brightness"] = {
model.id: (None if isinstance(value, BaseException) else value)
for model, value in zip(device_models, brightness_values)
}
if "css_sources" in sections:
css = await list_color_strip_sources(_auth, css_store, manager)
result["css_sources"] = css.sources
if "value_sources" in sections:
result["value_sources"] = (await list_value_sources(_auth, None, value_store)).sources
if "scene_presets" in sections:
result["scene_presets"] = (await list_scene_presets(_auth, preset_store)).presets
if "sync_clocks" in sections:
clocks = await list_sync_clocks(_auth, clock_store, clock_manager)
result["sync_clocks"] = clocks.clocks
if "system" in sections:
result["system"] = {
"performance": await _safe_section(
run_in_threadpool(get_system_performance, _auth), "system.performance"
),
"health": await _safe_section(health_check(request), "system.health"),
"update": await _safe_section(
_update_status_model(_auth, update_service), "system.update"
),
}
return result
+1 -2
View File
@@ -9,7 +9,6 @@ import subprocess
import sys
import time
from datetime import datetime, timezone
from typing import Optional
import os
@@ -190,7 +189,7 @@ async def list_all_tags(_: AuthRequired):
@router.get("/api/v1/config/displays", response_model=DisplayListResponse, tags=["Config"])
async def get_displays(
_: AuthRequired,
engine_type: Optional[str] = Query(None, description="Engine type to get displays for"),
engine_type: str | None = Query(None, description="Engine type to get displays for"),
):
"""Get list of available displays.
@@ -1,7 +1,7 @@
"""Value source routes: CRUD for value sources."""
import asyncio
from typing import Annotated, Optional
from typing import Annotated
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
@@ -289,7 +289,7 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
@router.get("/api/v1/value-sources", response_model=ValueSourceListResponse, tags=["Value Sources"])
async def list_value_sources(
_auth: AuthRequired,
source_type: Optional[str] = Query(
source_type: str | None = Query(
None,
description="Filter by source_type: static, animated, audio, adaptive_time, or adaptive_scene",
),
+9 -9
View File
@@ -1,7 +1,7 @@
"""Asset schemas (CRUD)."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -9,15 +9,15 @@ from pydantic import BaseModel, Field
class AssetUpdate(BaseModel):
"""Request to update asset metadata."""
name: Optional[str] = Field(None, min_length=1, max_length=100, description="Display name")
description: Optional[str] = Field(None, max_length=500, description="Optional description")
tags: Optional[List[str]] = Field(None, description="User-defined tags")
icon: Optional[str] = Field(
name: str | None = Field(None, min_length=1, max_length=100, description="Display name")
description: str | None = Field(None, max_length=500, description="Optional description")
tags: List[str] | None = Field(None, description="User-defined tags")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -33,15 +33,15 @@ class AssetResponse(BaseModel):
mime_type: str = Field(description="MIME type")
asset_type: str = Field(description="Asset type: sound, image, video, other")
size_bytes: int = Field(description="File size in bytes")
description: Optional[str] = Field(None, description="Description")
description: str | None = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
prebuilt: bool = Field(False, description="Whether this is a shipped prebuilt asset")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -1,7 +1,7 @@
"""Audio processing template schemas."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -15,14 +15,14 @@ class AudioProcessingTemplateCreate(BaseModel):
filters: List[FilterInstanceSchema] = Field(
default_factory=list, description="Ordered list of audio filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -32,18 +32,18 @@ class AudioProcessingTemplateCreate(BaseModel):
class AudioProcessingTemplateUpdate(BaseModel):
"""Request to update an audio processing template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
filters: Optional[List[FilterInstanceSchema]] = Field(
name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
filters: List[FilterInstanceSchema] | None = Field(
None, description="Ordered list of audio filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -61,13 +61,13 @@ class AudioProcessingTemplateResponse(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
+25 -31
View File
@@ -1,7 +1,7 @@
"""Audio source schemas — discriminated unions per source type."""
from datetime import datetime
from typing import Annotated, List, Literal, Optional, Union
from typing import Annotated, List, Literal
from pydantic import BaseModel, Discriminator, Field, Tag
@@ -15,16 +15,16 @@ class _AudioSourceResponseBase(BaseModel):
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
description: Optional[str] = Field(None, description="Description")
description: str | None = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -35,7 +35,7 @@ class CaptureAudioSourceResponse(_AudioSourceResponseBase):
source_type: Literal["capture"] = "capture"
device_index: int = Field(description="Audio device index (-1 = default)")
is_loopback: bool = Field(description="WASAPI loopback mode")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
audio_template_id: str | None = Field(None, description="Audio capture template ID")
class ProcessedAudioSourceResponse(_AudioSourceResponseBase):
@@ -45,10 +45,8 @@ class ProcessedAudioSourceResponse(_AudioSourceResponseBase):
AudioSourceResponse = Annotated[
Union[
Annotated[CaptureAudioSourceResponse, Tag("capture")],
Annotated[ProcessedAudioSourceResponse, Tag("processed")],
],
Annotated[CaptureAudioSourceResponse, Tag("capture")]
| Annotated[ProcessedAudioSourceResponse, Tag("processed")],
Discriminator("source_type"),
]
@@ -61,14 +59,14 @@ class _AudioSourceCreateBase(BaseModel):
"""Shared fields for all audio source create requests."""
name: str = Field(description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -79,7 +77,7 @@ class CaptureAudioSourceCreate(_AudioSourceCreateBase):
source_type: Literal["capture"] = "capture"
device_index: int = Field(-1, description="Audio device index (-1 = default)")
is_loopback: bool = Field(True, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
audio_template_id: str | None = Field(None, description="Audio capture template ID")
class ProcessedAudioSourceCreate(_AudioSourceCreateBase):
@@ -89,10 +87,8 @@ class ProcessedAudioSourceCreate(_AudioSourceCreateBase):
AudioSourceCreate = Annotated[
Union[
Annotated[CaptureAudioSourceCreate, Tag("capture")],
Annotated[ProcessedAudioSourceCreate, Tag("processed")],
],
Annotated[CaptureAudioSourceCreate, Tag("capture")]
| Annotated[ProcessedAudioSourceCreate, Tag("processed")],
Discriminator("source_type"),
]
@@ -104,15 +100,15 @@ AudioSourceCreate = Annotated[
class _AudioSourceUpdateBase(BaseModel):
"""Shared fields for all audio source update requests."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -121,24 +117,22 @@ class _AudioSourceUpdateBase(BaseModel):
class CaptureAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["capture"] = "capture"
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
device_index: int | None = Field(None, description="Audio device index (-1 = default)")
is_loopback: bool | None = Field(None, description="True for system audio (WASAPI loopback)")
audio_template_id: str | None = Field(None, description="Audio capture template ID")
class ProcessedAudioSourceUpdate(_AudioSourceUpdateBase):
source_type: Literal["processed"] = "processed"
audio_source_id: Optional[str] = Field(None, description="Input audio source ID")
audio_processing_template_id: Optional[str] = Field(
audio_source_id: str | None = Field(None, description="Input audio source ID")
audio_processing_template_id: str | None = Field(
None, description="Audio processing template ID"
)
AudioSourceUpdate = Annotated[
Union[
Annotated[CaptureAudioSourceUpdate, Tag("capture")],
Annotated[ProcessedAudioSourceUpdate, Tag("processed")],
],
Annotated[CaptureAudioSourceUpdate, Tag("capture")]
| Annotated[ProcessedAudioSourceUpdate, Tag("processed")],
Discriminator("source_type"),
]
@@ -1,7 +1,7 @@
"""Audio capture template and engine schemas."""
from datetime import datetime
from typing import Dict, List, Optional
from typing import Dict, List
from pydantic import BaseModel, Field
@@ -14,14 +14,14 @@ class AudioTemplateCreate(BaseModel):
description="Audio engine type (e.g., 'wasapi', 'sounddevice')", min_length=1
)
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -31,17 +31,17 @@ class AudioTemplateCreate(BaseModel):
class AudioTemplateUpdate(BaseModel):
"""Request to update an audio template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
engine_type: Optional[str] = Field(None, description="Audio engine type")
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
engine_type: str | None = Field(None, description="Audio engine type")
engine_config: Dict | None = Field(None, description="Engine-specific configuration")
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -58,13 +58,13 @@ class AudioTemplateResponse(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
+39 -45
View File
@@ -1,7 +1,7 @@
"""Automation-related schemas."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -11,57 +11,53 @@ class RuleSchema(BaseModel):
rule_type: str = Field(description="Rule type discriminator (e.g. 'application')")
# Application rule fields
apps: Optional[List[str]] = Field(None, description="Process names (for application rule)")
match_type: Optional[str] = Field(
apps: List[str] | None = Field(None, description="Process names (for application rule)")
match_type: str | None = Field(
None, description="'running' or 'topmost' (for application rule)"
)
# Time-of-day rule fields
start_time: Optional[str] = Field(None, description="Start time HH:MM (for time_of_day rule)")
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day rule)")
start_time: str | None = Field(None, description="Start time HH:MM (for time_of_day rule)")
end_time: str | None = Field(None, description="End time HH:MM (for time_of_day rule)")
# System idle rule fields
idle_minutes: Optional[int] = Field(
idle_minutes: int | None = Field(
None, description="Idle timeout in minutes (for system_idle rule)"
)
when_idle: Optional[bool] = Field(
None, description="True=active when idle (for system_idle rule)"
)
when_idle: bool | None = Field(None, description="True=active when idle (for system_idle rule)")
# Display state rule fields
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state rule)")
state: str | None = Field(None, description="'on' or 'off' (for display_state rule)")
# MQTT rule fields
mqtt_source_id: Optional[str] = Field(None, description="MQTT source ID (for mqtt rule)")
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt rule)")
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt rule)")
match_mode: Optional[str] = Field(
mqtt_source_id: str | None = Field(None, description="MQTT source ID (for mqtt rule)")
topic: str | None = Field(None, description="MQTT topic to watch (for mqtt rule)")
payload: str | None = Field(None, description="Expected payload value (for mqtt rule)")
match_mode: str | None = Field(
None, description="'exact', 'contains', or 'regex' (for mqtt rule)"
)
# Webhook rule fields
token: Optional[str] = Field(
None, description="Secret token for webhook URL (for webhook rule)"
)
token: str | None = Field(None, description="Secret token for webhook URL (for webhook rule)")
# Home Assistant rule fields
ha_source_id: Optional[str] = Field(
ha_source_id: str | None = Field(
None, description="Home Assistant source ID (for home_assistant rule)"
)
entity_id: Optional[str] = Field(
entity_id: str | None = Field(
None,
description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant rule)",
)
# HTTP poll rule fields
value_source_id: Optional[str] = Field(
value_source_id: str | None = Field(
None,
description=(
"Value source ID (for http_poll rule). The referenced "
"ValueSource must be of source_type='http'."
),
)
operator: Optional[str] = Field(
operator: str | None = Field(
None,
description=(
"Comparison operator for http_poll rule: "
"'equals', 'not_equals', 'contains', 'regex', 'gt', 'lt', 'exists'."
),
)
value: Optional[str] = Field(
value: str | None = Field(
None, description="Expected value (for http_poll rule; ignored for 'exists')"
)
@@ -77,20 +73,20 @@ class AutomationCreate(BaseModel):
enabled: bool = Field(default=True, description="Whether the automation is enabled")
rule_logic: str = Field(default="or", description="How rules combine: 'or' or 'and'")
rules: List[RuleSchema] = Field(default_factory=list, description="List of rules")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
scene_preset_id: str | None = Field(None, description="Scene preset to activate")
deactivation_mode: str = Field(
default="none", description="'none', 'revert', or 'fallback_scene'"
)
deactivation_scene_preset_id: Optional[str] = Field(
deactivation_scene_preset_id: str | None = Field(
None, description="Scene preset for fallback deactivation"
)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -100,24 +96,22 @@ class AutomationCreate(BaseModel):
class AutomationUpdate(BaseModel):
"""Request to update an automation."""
name: Optional[str] = Field(None, description="Automation name", min_length=1, max_length=100)
enabled: Optional[bool] = Field(None, description="Whether the automation is enabled")
rule_logic: Optional[str] = Field(None, description="How rules combine: 'or' or 'and'")
rules: Optional[List[RuleSchema]] = Field(None, description="List of rules")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
deactivation_mode: Optional[str] = Field(
None, description="'none', 'revert', or 'fallback_scene'"
)
deactivation_scene_preset_id: Optional[str] = Field(
name: str | None = Field(None, description="Automation name", min_length=1, max_length=100)
enabled: bool | None = Field(None, description="Whether the automation is enabled")
rule_logic: str | None = Field(None, description="How rules combine: 'or' or 'and'")
rules: List[RuleSchema] | None = Field(None, description="List of rules")
scene_preset_id: str | None = Field(None, description="Scene preset to activate")
deactivation_mode: str | None = Field(None, description="'none', 'revert', or 'fallback_scene'")
deactivation_scene_preset_id: str | None = Field(
None, description="Scene preset for fallback deactivation"
)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -132,26 +126,26 @@ class AutomationResponse(BaseModel):
enabled: bool = Field(description="Whether the automation is enabled")
rule_logic: str = Field(description="Rule combination logic")
rules: List[RuleSchema] = Field(description="List of rules")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
scene_preset_id: str | None = Field(None, description="Scene preset to activate")
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset")
deactivation_scene_preset_id: str | None = Field(None, description="Fallback scene preset")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
webhook_url: Optional[str] = Field(
webhook_url: str | None = Field(
None, description="Webhook URL for the first webhook rule (if any)"
)
is_active: bool = Field(default=False, description="Whether the automation is currently active")
last_activated_at: Optional[datetime] = Field(
last_activated_at: datetime | None = Field(
None, description="Last time this automation was activated"
)
last_deactivated_at: Optional[datetime] = Field(
last_deactivated_at: datetime | None = Field(
None, description="Last time this automation was deactivated"
)
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -1,7 +1,7 @@
"""Color strip processing template schemas."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -15,14 +15,14 @@ class ColorStripProcessingTemplateCreate(BaseModel):
filters: List[FilterInstanceSchema] = Field(
default_factory=list, description="Ordered list of filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -32,18 +32,18 @@ class ColorStripProcessingTemplateCreate(BaseModel):
class ColorStripProcessingTemplateUpdate(BaseModel):
"""Request to update a color strip processing template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
filters: Optional[List[FilterInstanceSchema]] = Field(
name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
filters: List[FilterInstanceSchema] | None = Field(
None, description="Ordered list of filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -59,13 +59,13 @@ class ColorStripProcessingTemplateResponse(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -1,13 +1,12 @@
"""Color strip source schemas — discriminated unions per source type."""
from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal, Optional, Union
from typing import Annotated, Any, Dict, List, Literal
from pydantic import BaseModel, Discriminator, Field, Tag, model_validator
from ledgrab.api.schemas.devices import Calibration
# =====================================================================
# Helper models (unchanged)
# =====================================================================
@@ -16,10 +15,10 @@ from ledgrab.api.schemas.devices import Calibration
class AppSoundOverride(BaseModel):
"""Per-application sound override for notification sources."""
sound_asset_id: Optional[str] = Field(
sound_asset_id: str | None = Field(
None, description="Asset ID for the sound (None = mute this app)"
)
volume: Optional[float] = Field(
volume: float | None = Field(
None, ge=0.0, le=1.0, description="Volume override (None = use global)"
)
@@ -39,7 +38,7 @@ class ColorStop(BaseModel):
description="Relative position along the strip (0.0-1.0)", ge=0.0, le=1.0
)
color: List[int] = Field(description="Primary RGB color [R, G, B] (0-255 each)")
color_right: Optional[List[int]] = Field(
color_right: List[int] | None = Field(
None,
description="Optional right-side RGB color for a hard edge (bidirectional stop)",
)
@@ -54,10 +53,10 @@ class CompositeLayer(BaseModel):
)
opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0")
enabled: bool = Field(default=True, description="Whether this layer is active")
brightness_source_id: Optional[str] = Field(
brightness_source_id: str | None = Field(
None, description="Optional value source ID for dynamic brightness"
)
processing_template_id: Optional[str] = Field(
processing_template_id: str | None = Field(
None, description="Optional color strip processing template ID"
)
start: int = Field(default=0, ge=0, description="First LED index for range (0 = full strip)")
@@ -86,21 +85,21 @@ class _CSSResponseBase(BaseModel):
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
description: Optional[str] = Field(None, description="Description")
description: str | None = Field(None, description="Description")
led_count: int = Field(0, description="Total LED count (0 = auto)")
overlay_active: bool = Field(
False, description="Whether the screen overlay is currently active"
)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
clock_id: str | None = Field(None, description="Optional sync clock ID")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -112,40 +111,40 @@ class PictureCSSResponse(_CSSResponseBase):
picture_source_id: str = Field(description="Picture source ID")
smoothing: Any = Field(description="Temporal smoothing")
interpolation_mode: str = Field(description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
calibration: Calibration | None = Field(None, description="LED calibration")
class PictureAdvancedCSSResponse(_CSSResponseBase):
source_type: Literal["picture_advanced"] = "picture_advanced"
smoothing: Any = Field(description="Temporal smoothing")
interpolation_mode: str = Field(description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
calibration: Calibration | None = Field(None, description="LED calibration")
class SingleColorCSSResponse(_CSSResponseBase):
source_type: Literal["single_color"] = "single_color"
color: Any = Field(description="Solid RGB color")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
animation: AnimationConfig | None = Field(None, description="Procedural animation config")
class GradientCSSResponse(_CSSResponseBase):
source_type: Literal["gradient"] = "gradient"
stops: Optional[List[ColorStop]] = Field(None, description="Color stops")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
stops: List[ColorStop] | None = Field(None, description="Color stops")
animation: AnimationConfig | None = Field(None, description="Procedural animation config")
easing: str = Field(description="Gradient interpolation easing")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
gradient_id: str | None = Field(None, description="Gradient entity ID")
class EffectCSSResponse(_CSSResponseBase):
source_type: Literal["effect"] = "effect"
effect_type: str = Field(description="Effect algorithm")
palette: str = Field(description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(description="Primary color")
intensity: Any = Field(description="Effect intensity")
scale: Any = Field(description="Spatial scale")
mirror: bool = Field(description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
class CompositeCSSResponse(_CSSResponseBase):
@@ -165,7 +164,7 @@ class AudioCSSResponse(_CSSResponseBase):
sensitivity: Any = Field(description="Audio sensitivity")
smoothing: Any = Field(description="Temporal smoothing")
palette: str = Field(description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(description="Primary color")
color_peak: Any = Field(description="Peak color")
mirror: bool = Field(description="Mirror mode")
@@ -188,7 +187,7 @@ class NotificationCSSResponse(_CSSResponseBase):
app_filter_mode: str = Field(description="App filter mode")
app_filter_list: List[str] = Field(default_factory=list, description="App names for filter")
os_listener: bool = Field(description="Whether to listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
sound_asset_id: str | None = Field(None, description="Global notification sound asset ID")
sound_volume: Any = Field(description="Global notification sound volume")
app_sounds: Dict[str, dict] = Field(default_factory=dict, description="Per-app sound overrides")
@@ -237,7 +236,7 @@ class MathWaveCSSResponse(_CSSResponseBase):
source_type: Literal["math_wave"] = "math_wave"
waves: List[dict] = Field(description="Wave layer definitions")
speed: Any = Field(description="Global speed multiplier (bindable)")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
gradient_id: str | None = Field(None, description="Gradient entity ID for color mapping")
class GameEventCSSResponse(_CSSResponseBase):
@@ -248,25 +247,23 @@ class GameEventCSSResponse(_CSSResponseBase):
ColorStripSourceResponse = Annotated[
Union[
Annotated[PictureCSSResponse, Tag("picture")],
Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")],
Annotated[SingleColorCSSResponse, Tag("single_color")],
Annotated[GradientCSSResponse, Tag("gradient")],
Annotated[EffectCSSResponse, Tag("effect")],
Annotated[CompositeCSSResponse, Tag("composite")],
Annotated[MappedCSSResponse, Tag("mapped")],
Annotated[AudioCSSResponse, Tag("audio")],
Annotated[ApiInputCSSResponse, Tag("api_input")],
Annotated[NotificationCSSResponse, Tag("notification")],
Annotated[DaylightCSSResponse, Tag("daylight")],
Annotated[CandlelightCSSResponse, Tag("candlelight")],
Annotated[ProcessedCSSResponse, Tag("processed")],
Annotated[WeatherCSSResponse, Tag("weather")],
Annotated[KeyColorsCSSResponse, Tag("key_colors")],
Annotated[MathWaveCSSResponse, Tag("math_wave")],
Annotated[GameEventCSSResponse, Tag("game_event")],
],
Annotated[PictureCSSResponse, Tag("picture")]
| Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")]
| Annotated[SingleColorCSSResponse, Tag("single_color")]
| Annotated[GradientCSSResponse, Tag("gradient")]
| Annotated[EffectCSSResponse, Tag("effect")]
| Annotated[CompositeCSSResponse, Tag("composite")]
| Annotated[MappedCSSResponse, Tag("mapped")]
| Annotated[AudioCSSResponse, Tag("audio")]
| Annotated[ApiInputCSSResponse, Tag("api_input")]
| Annotated[NotificationCSSResponse, Tag("notification")]
| Annotated[DaylightCSSResponse, Tag("daylight")]
| Annotated[CandlelightCSSResponse, Tag("candlelight")]
| Annotated[ProcessedCSSResponse, Tag("processed")]
| Annotated[WeatherCSSResponse, Tag("weather")]
| Annotated[KeyColorsCSSResponse, Tag("key_colors")]
| Annotated[MathWaveCSSResponse, Tag("math_wave")]
| Annotated[GameEventCSSResponse, Tag("game_event")],
Discriminator("source_type"),
]
@@ -281,15 +278,15 @@ class _CSSCreateBase(BaseModel):
name: str = Field(description="Source name", min_length=1, max_length=100)
led_count: int = Field(default=0, description="Total LED count (0 = auto)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
description: str | None = Field(None, description="Optional description", max_length=500)
clock_id: str | None = Field(None, description="Optional sync clock ID")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -301,63 +298,63 @@ class PictureCSSCreate(_CSSCreateBase):
picture_source_id: str = Field(default="", description="Picture source ID")
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: str = Field(default="average", description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
calibration: Calibration | None = Field(None, description="LED calibration")
class PictureAdvancedCSSCreate(_CSSCreateBase):
source_type: Literal["picture_advanced"] = "picture_advanced"
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: str = Field(default="average", description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
calibration: Calibration | None = Field(None, description="LED calibration")
class SingleColorCSSCreate(_CSSCreateBase):
source_type: Literal["single_color"] = "single_color"
color: Any = Field(default=None, description="Solid RGB color [R,G,B]")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
animation: AnimationConfig | None = Field(None, description="Procedural animation config")
class GradientCSSCreate(_CSSCreateBase):
source_type: Literal["gradient"] = "gradient"
stops: Optional[List[ColorStop]] = Field(None, description="Color stops")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
easing: Optional[str] = Field(None, description="Gradient easing")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
stops: List[ColorStop] | None = Field(None, description="Color stops")
animation: AnimationConfig | None = Field(None, description="Procedural animation config")
easing: str | None = Field(None, description="Gradient easing")
gradient_id: str | None = Field(None, description="Gradient entity ID")
class EffectCSSCreate(_CSSCreateBase):
source_type: Literal["effect"] = "effect"
effect_type: Optional[str] = Field(None, description="Effect algorithm")
palette: Optional[str] = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
effect_type: str | None = Field(None, description="Effect algorithm")
palette: str | None = Field(None, description="Named palette")
gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color")
intensity: Any = Field(default=None, description="Effect intensity (0.1-2.0)")
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops")
mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
class CompositeCSSCreate(_CSSCreateBase):
source_type: Literal["composite"] = "composite"
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
layers: List[CompositeLayer] | None = Field(None, description="Layers for composite type")
class MappedCSSCreate(_CSSCreateBase):
source_type: Literal["mapped"] = "mapped"
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type")
zones: List[MappedZone] | None = Field(None, description="Zones for mapped type")
class AudioCSSCreate(_CSSCreateBase):
source_type: Literal["audio"] = "audio"
visualization_mode: Optional[str] = Field(None, description="Audio visualization mode")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
visualization_mode: str | None = Field(None, description="Audio visualization mode")
audio_source_id: str | None = Field(None, description="Mono audio source ID")
sensitivity: Any = Field(default=None, description="Audio sensitivity (0.1-5.0)")
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
palette: Optional[str] = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
palette: str | None = Field(None, description="Named palette")
gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color")
color_peak: Any = Field(default=None, description="Peak color [R,G,B]")
mirror: Optional[bool] = Field(None, description="Mirror mode")
mirror: bool | None = Field(None, description="Mirror mode")
beat_decay: Any = Field(
default=None, description="Beat pulse decay rate (music modes, 0.01-0.5)"
)
@@ -367,23 +364,23 @@ class ApiInputCSSCreate(_CSSCreateBase):
source_type: Literal["api_input"] = "api_input"
fallback_color: Any = Field(default=None, description="Fallback RGB color [R,G,B]")
timeout: Any = Field(default=None, description="Timeout before fallback (0.0-300.0)")
interpolation: Optional[str] = Field(None, description="LED count interpolation mode")
interpolation: str | None = Field(None, description="LED count interpolation mode")
class NotificationCSSCreate(_CSSCreateBase):
source_type: Literal["notification"] = "notification"
notification_effect: Optional[str] = Field(None, description="Notification effect")
notification_effect: str | None = Field(None, description="Notification effect")
duration_ms: Any = Field(default=None, description="Effect duration in milliseconds")
default_color: Optional[Union[List[int], Dict[str, Any], str]] = Field(
default_color: List[int] | Dict[str, Any] | str | None = Field(
None, description="Default color"
)
app_colors: Optional[Dict[str, str]] = Field(None, description="Per-app hex colors")
app_filter_mode: Optional[str] = Field(None, description="App filter mode")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
app_colors: Dict[str, str] | None = Field(None, description="Per-app hex colors")
app_filter_mode: str | None = Field(None, description="App filter mode")
app_filter_list: List[str] | None = Field(None, description="App names for filter")
os_listener: bool | None = Field(None, description="Listen for OS notifications")
sound_asset_id: str | None = Field(None, description="Global notification sound asset ID")
sound_volume: Any = Field(default=None, description="Global notification sound volume")
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field(
app_sounds: Dict[str, AppSoundOverride] | None = Field(
None, description="Per-app sound overrides"
)
@@ -391,9 +388,9 @@ class NotificationCSSCreate(_CSSCreateBase):
class DaylightCSSCreate(_CSSCreateBase):
source_type: Literal["daylight"] = "daylight"
speed: Any = Field(default=None, description="Cycle speed multiplier (0.1-10.0)")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(
use_real_time: bool | None = Field(None, description="Use wall-clock time")
latitude: float | None = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: float | None = Field(
None, description="Longitude (-180 to 180)", ge=-180.0, le=180.0
)
@@ -402,23 +399,23 @@ class CandlelightCSSCreate(_CSSCreateBase):
source_type: Literal["candlelight"] = "candlelight"
color: Any = Field(default=None, description="Candle color [R,G,B]")
intensity: Any = Field(default=None, description="Candle intensity (0.1-2.0)")
num_candles: Optional[int] = Field(
num_candles: int | None = Field(
None, description="Number of candle sources (1-20)", ge=1, le=20
)
speed: Any = Field(default=None, description="Flicker speed (0.1-10.0)")
wind_strength: Any = Field(default=None, description="Wind strength (0.0-2.0)")
candle_type: Optional[str] = Field(None, description="Candle type preset")
candle_type: str | None = Field(None, description="Candle type preset")
class ProcessedCSSCreate(_CSSCreateBase):
source_type: Literal["processed"] = "processed"
input_source_id: Optional[str] = Field(None, description="Input color strip source ID")
processing_template_id: Optional[str] = Field(None, description="Processing template ID")
input_source_id: str | None = Field(None, description="Input color strip source ID")
processing_template_id: str | None = Field(None, description="Processing template ID")
class WeatherCSSCreate(_CSSCreateBase):
source_type: Literal["weather"] = "weather"
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID")
weather_source_id: str | None = Field(None, description="Weather source entity ID")
speed: Any = Field(default=None, description="Speed multiplier (0.1-10.0)")
temperature_influence: Any = Field(default=None, description="Temperature influence (0.0-1.0)")
@@ -426,49 +423,47 @@ class WeatherCSSCreate(_CSSCreateBase):
class KeyColorsCSSCreate(_CSSCreateBase):
source_type: Literal["key_colors"] = "key_colors"
picture_source_id: str = Field(default="", description="Picture source ID")
rectangles: Optional[List[dict]] = Field(None, description="Named screen regions")
rectangles: List[dict] | None = Field(None, description="Named screen regions")
interpolation_mode: str = Field(default="average", description="Interpolation mode")
smoothing: Any = Field(default=0.3, description="Temporal smoothing (0.0-1.0)")
brightness: Any = Field(default=None, description="Brightness (0.0-1.0)")
brightness_value_source_id: Optional[str] = Field(
brightness_value_source_id: str | None = Field(
None, description="Dynamic brightness value source ID"
)
class MathWaveCSSCreate(_CSSCreateBase):
source_type: Literal["math_wave"] = "math_wave"
waves: Optional[List[dict]] = Field(None, description="Wave layer definitions")
waves: List[dict] | None = Field(None, description="Wave layer definitions")
speed: Any = Field(default=None, description="Global speed multiplier (bindable, 0.1-10.0)")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
gradient_id: str | None = Field(None, description="Gradient entity ID for color mapping")
class GameEventCSSCreate(_CSSCreateBase):
source_type: Literal["game_event"] = "game_event"
game_integration_id: Optional[str] = Field(None, description="Game integration entity ID")
game_integration_id: str | None = Field(None, description="Game integration entity ID")
idle_color: Any = Field(default=None, description="Idle RGB color [R,G,B] (bindable)")
event_mappings: Optional[List[dict]] = Field(None, description="Event-to-effect mappings")
event_mappings: List[dict] | None = Field(None, description="Event-to-effect mappings")
ColorStripSourceCreate = Annotated[
Union[
Annotated[PictureCSSCreate, Tag("picture")],
Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")],
Annotated[SingleColorCSSCreate, Tag("single_color")],
Annotated[GradientCSSCreate, Tag("gradient")],
Annotated[EffectCSSCreate, Tag("effect")],
Annotated[CompositeCSSCreate, Tag("composite")],
Annotated[MappedCSSCreate, Tag("mapped")],
Annotated[AudioCSSCreate, Tag("audio")],
Annotated[ApiInputCSSCreate, Tag("api_input")],
Annotated[NotificationCSSCreate, Tag("notification")],
Annotated[DaylightCSSCreate, Tag("daylight")],
Annotated[CandlelightCSSCreate, Tag("candlelight")],
Annotated[ProcessedCSSCreate, Tag("processed")],
Annotated[WeatherCSSCreate, Tag("weather")],
Annotated[KeyColorsCSSCreate, Tag("key_colors")],
Annotated[MathWaveCSSCreate, Tag("math_wave")],
Annotated[GameEventCSSCreate, Tag("game_event")],
],
Annotated[PictureCSSCreate, Tag("picture")]
| Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")]
| Annotated[SingleColorCSSCreate, Tag("single_color")]
| Annotated[GradientCSSCreate, Tag("gradient")]
| Annotated[EffectCSSCreate, Tag("effect")]
| Annotated[CompositeCSSCreate, Tag("composite")]
| Annotated[MappedCSSCreate, Tag("mapped")]
| Annotated[AudioCSSCreate, Tag("audio")]
| Annotated[ApiInputCSSCreate, Tag("api_input")]
| Annotated[NotificationCSSCreate, Tag("notification")]
| Annotated[DaylightCSSCreate, Tag("daylight")]
| Annotated[CandlelightCSSCreate, Tag("candlelight")]
| Annotated[ProcessedCSSCreate, Tag("processed")]
| Annotated[WeatherCSSCreate, Tag("weather")]
| Annotated[KeyColorsCSSCreate, Tag("key_colors")]
| Annotated[MathWaveCSSCreate, Tag("math_wave")]
| Annotated[GameEventCSSCreate, Tag("game_event")],
Discriminator("source_type"),
]
@@ -481,17 +476,17 @@ ColorStripSourceCreate = Annotated[
class _CSSUpdateBase(BaseModel):
"""Shared fields for all color strip source update requests."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
clock_id: Optional[str] = Field(None, description="Optional sync clock ID")
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
led_count: int | None = Field(None, description="Total LED count (0 = auto)", ge=0)
description: str | None = Field(None, description="Optional description", max_length=500)
clock_id: str | None = Field(None, description="Optional sync clock ID")
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -500,66 +495,66 @@ class _CSSUpdateBase(BaseModel):
class PictureCSSUpdate(_CSSUpdateBase):
source_type: Literal["picture"] = "picture"
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
picture_source_id: str | None = Field(None, description="Picture source ID")
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
interpolation_mode: str | None = Field(None, description="Interpolation mode")
calibration: Calibration | None = Field(None, description="LED calibration")
class PictureAdvancedCSSUpdate(_CSSUpdateBase):
source_type: Literal["picture_advanced"] = "picture_advanced"
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
calibration: Optional[Calibration] = Field(None, description="LED calibration")
interpolation_mode: str | None = Field(None, description="Interpolation mode")
calibration: Calibration | None = Field(None, description="LED calibration")
class SingleColorCSSUpdate(_CSSUpdateBase):
source_type: Literal["single_color"] = "single_color"
color: Any = Field(default=None, description="Solid RGB color [R,G,B]")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
animation: AnimationConfig | None = Field(None, description="Procedural animation config")
class GradientCSSUpdate(_CSSUpdateBase):
source_type: Literal["gradient"] = "gradient"
stops: Optional[List[ColorStop]] = Field(None, description="Color stops")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config")
easing: Optional[str] = Field(None, description="Gradient easing")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
stops: List[ColorStop] | None = Field(None, description="Color stops")
animation: AnimationConfig | None = Field(None, description="Procedural animation config")
easing: str | None = Field(None, description="Gradient easing")
gradient_id: str | None = Field(None, description="Gradient entity ID")
class EffectCSSUpdate(_CSSUpdateBase):
source_type: Literal["effect"] = "effect"
effect_type: Optional[str] = Field(None, description="Effect algorithm")
palette: Optional[str] = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
effect_type: str | None = Field(None, description="Effect algorithm")
palette: str | None = Field(None, description="Named palette")
gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color")
intensity: Any = Field(default=None, description="Effect intensity (0.1-2.0)")
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops")
mirror: bool | None = Field(None, description="Mirror/bounce mode")
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
class CompositeCSSUpdate(_CSSUpdateBase):
source_type: Literal["composite"] = "composite"
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
layers: List[CompositeLayer] | None = Field(None, description="Layers for composite type")
class MappedCSSUpdate(_CSSUpdateBase):
source_type: Literal["mapped"] = "mapped"
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type")
zones: List[MappedZone] | None = Field(None, description="Zones for mapped type")
class AudioCSSUpdate(_CSSUpdateBase):
source_type: Literal["audio"] = "audio"
visualization_mode: Optional[str] = Field(None, description="Audio visualization mode")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
visualization_mode: str | None = Field(None, description="Audio visualization mode")
audio_source_id: str | None = Field(None, description="Mono audio source ID")
sensitivity: Any = Field(default=None, description="Audio sensitivity (0.1-5.0)")
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
palette: Optional[str] = Field(None, description="Named palette")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
palette: str | None = Field(None, description="Named palette")
gradient_id: str | None = Field(None, description="Gradient entity ID")
color: Any = Field(default=None, description="Primary color")
color_peak: Any = Field(default=None, description="Peak color [R,G,B]")
mirror: Optional[bool] = Field(None, description="Mirror mode")
mirror: bool | None = Field(None, description="Mirror mode")
beat_decay: Any = Field(default=None, description="Beat pulse decay rate (music modes)")
@@ -567,23 +562,23 @@ class ApiInputCSSUpdate(_CSSUpdateBase):
source_type: Literal["api_input"] = "api_input"
fallback_color: Any = Field(default=None, description="Fallback RGB color [R,G,B]")
timeout: Any = Field(default=None, description="Timeout before fallback (0.0-300.0)")
interpolation: Optional[str] = Field(None, description="LED count interpolation mode")
interpolation: str | None = Field(None, description="LED count interpolation mode")
class NotificationCSSUpdate(_CSSUpdateBase):
source_type: Literal["notification"] = "notification"
notification_effect: Optional[str] = Field(None, description="Notification effect")
notification_effect: str | None = Field(None, description="Notification effect")
duration_ms: Any = Field(default=None, description="Effect duration in milliseconds")
default_color: Optional[Union[List[int], Dict[str, Any], str]] = Field(
default_color: List[int] | Dict[str, Any] | str | None = Field(
None, description="Default color"
)
app_colors: Optional[Dict[str, str]] = Field(None, description="Per-app hex colors")
app_filter_mode: Optional[str] = Field(None, description="App filter mode")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
app_colors: Dict[str, str] | None = Field(None, description="Per-app hex colors")
app_filter_mode: str | None = Field(None, description="App filter mode")
app_filter_list: List[str] | None = Field(None, description="App names for filter")
os_listener: bool | None = Field(None, description="Listen for OS notifications")
sound_asset_id: str | None = Field(None, description="Global notification sound asset ID")
sound_volume: Any = Field(default=None, description="Global notification sound volume")
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field(
app_sounds: Dict[str, AppSoundOverride] | None = Field(
None, description="Per-app sound overrides"
)
@@ -591,9 +586,9 @@ class NotificationCSSUpdate(_CSSUpdateBase):
class DaylightCSSUpdate(_CSSUpdateBase):
source_type: Literal["daylight"] = "daylight"
speed: Any = Field(default=None, description="Cycle speed multiplier (0.1-10.0)")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(
use_real_time: bool | None = Field(None, description="Use wall-clock time")
latitude: float | None = Field(None, description="Latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: float | None = Field(
None, description="Longitude (-180 to 180)", ge=-180.0, le=180.0
)
@@ -602,73 +597,71 @@ class CandlelightCSSUpdate(_CSSUpdateBase):
source_type: Literal["candlelight"] = "candlelight"
color: Any = Field(default=None, description="Candle color [R,G,B]")
intensity: Any = Field(default=None, description="Candle intensity (0.1-2.0)")
num_candles: Optional[int] = Field(
num_candles: int | None = Field(
None, description="Number of candle sources (1-20)", ge=1, le=20
)
speed: Any = Field(default=None, description="Flicker speed (0.1-10.0)")
wind_strength: Any = Field(default=None, description="Wind strength (0.0-2.0)")
candle_type: Optional[str] = Field(None, description="Candle type preset")
candle_type: str | None = Field(None, description="Candle type preset")
class ProcessedCSSUpdate(_CSSUpdateBase):
source_type: Literal["processed"] = "processed"
input_source_id: Optional[str] = Field(None, description="Input color strip source ID")
processing_template_id: Optional[str] = Field(None, description="Processing template ID")
input_source_id: str | None = Field(None, description="Input color strip source ID")
processing_template_id: str | None = Field(None, description="Processing template ID")
class WeatherCSSUpdate(_CSSUpdateBase):
source_type: Literal["weather"] = "weather"
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID")
weather_source_id: str | None = Field(None, description="Weather source entity ID")
speed: Any = Field(default=None, description="Speed multiplier (0.1-10.0)")
temperature_influence: Any = Field(default=None, description="Temperature influence (0.0-1.0)")
class KeyColorsCSSUpdate(_CSSUpdateBase):
source_type: Literal["key_colors"] = "key_colors"
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
rectangles: Optional[List[dict]] = Field(None, description="Named screen regions")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
picture_source_id: str | None = Field(None, description="Picture source ID")
rectangles: List[dict] | None = Field(None, description="Named screen regions")
interpolation_mode: str | None = Field(None, description="Interpolation mode")
smoothing: Any = Field(default=None, description="Temporal smoothing (0.0-1.0)")
brightness: Any = Field(default=None, description="Brightness (0.0-1.0)")
brightness_value_source_id: Optional[str] = Field(
brightness_value_source_id: str | None = Field(
None, description="Dynamic brightness value source ID"
)
class MathWaveCSSUpdate(_CSSUpdateBase):
source_type: Literal["math_wave"] = "math_wave"
waves: Optional[List[dict]] = Field(None, description="Wave layer definitions")
waves: List[dict] | None = Field(None, description="Wave layer definitions")
speed: Any = Field(default=None, description="Global speed multiplier (bindable)")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
gradient_id: str | None = Field(None, description="Gradient entity ID for color mapping")
class GameEventCSSUpdate(_CSSUpdateBase):
source_type: Literal["game_event"] = "game_event"
game_integration_id: Optional[str] = Field(None, description="Game integration entity ID")
game_integration_id: str | None = Field(None, description="Game integration entity ID")
idle_color: Any = Field(default=None, description="Idle RGB color [R,G,B] (bindable)")
event_mappings: Optional[List[dict]] = Field(None, description="Event-to-effect mappings")
event_mappings: List[dict] | None = Field(None, description="Event-to-effect mappings")
ColorStripSourceUpdate = Annotated[
Union[
Annotated[PictureCSSUpdate, Tag("picture")],
Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")],
Annotated[SingleColorCSSUpdate, Tag("single_color")],
Annotated[GradientCSSUpdate, Tag("gradient")],
Annotated[EffectCSSUpdate, Tag("effect")],
Annotated[CompositeCSSUpdate, Tag("composite")],
Annotated[MappedCSSUpdate, Tag("mapped")],
Annotated[AudioCSSUpdate, Tag("audio")],
Annotated[ApiInputCSSUpdate, Tag("api_input")],
Annotated[NotificationCSSUpdate, Tag("notification")],
Annotated[DaylightCSSUpdate, Tag("daylight")],
Annotated[CandlelightCSSUpdate, Tag("candlelight")],
Annotated[ProcessedCSSUpdate, Tag("processed")],
Annotated[WeatherCSSUpdate, Tag("weather")],
Annotated[KeyColorsCSSUpdate, Tag("key_colors")],
Annotated[MathWaveCSSUpdate, Tag("math_wave")],
Annotated[GameEventCSSUpdate, Tag("game_event")],
],
Annotated[PictureCSSUpdate, Tag("picture")]
| Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")]
| Annotated[SingleColorCSSUpdate, Tag("single_color")]
| Annotated[GradientCSSUpdate, Tag("gradient")]
| Annotated[EffectCSSUpdate, Tag("effect")]
| Annotated[CompositeCSSUpdate, Tag("composite")]
| Annotated[MappedCSSUpdate, Tag("mapped")]
| Annotated[AudioCSSUpdate, Tag("audio")]
| Annotated[ApiInputCSSUpdate, Tag("api_input")]
| Annotated[NotificationCSSUpdate, Tag("notification")]
| Annotated[DaylightCSSUpdate, Tag("daylight")]
| Annotated[CandlelightCSSUpdate, Tag("candlelight")]
| Annotated[ProcessedCSSUpdate, Tag("processed")]
| Annotated[WeatherCSSUpdate, Tag("weather")]
| Annotated[KeyColorsCSSUpdate, Tag("key_colors")]
| Annotated[MathWaveCSSUpdate, Tag("math_wave")]
| Annotated[GameEventCSSUpdate, Tag("game_event")],
Discriminator("source_type"),
]
@@ -699,17 +692,17 @@ class SegmentPayload(BaseModel):
``color`` therefore fills the entire strip.
"""
start: Optional[int] = Field(
start: int | None = Field(
None, ge=0, description="Starting LED index (default 0 = beginning of strip)"
)
length: Optional[int] = Field(
length: int | None = Field(
None,
ge=1,
description="Number of LEDs in segment (default = led_count - start)",
)
mode: Literal["solid", "per_pixel", "gradient"] = Field(description="Fill mode")
color: Optional[List[int]] = Field(None, description="RGB for solid mode [R,G,B]")
colors: Optional[List[List[int]]] = Field(
color: List[int] | None = Field(None, description="RGB for solid mode [R,G,B]")
colors: List[List[int]] | None = Field(
None, description="Colors for per_pixel/gradient [[R,G,B],...]"
)
@@ -742,12 +735,10 @@ class ColorPushRequest(BaseModel):
At least one must be provided.
"""
colors: Optional[List[List[int]]] = Field(
colors: List[List[int]] | None = Field(
None, description="LED color array [[R,G,B], ...] (0-255 each)"
)
segments: Optional[List[SegmentPayload]] = Field(
None, description="Segment-based color updates"
)
segments: List[SegmentPayload] | None = Field(None, description="Segment-based color updates")
@model_validator(mode="after")
def _require_colors_or_segments(self) -> "ColorPushRequest":
@@ -759,8 +750,8 @@ class ColorPushRequest(BaseModel):
class NotifyRequest(BaseModel):
"""Request to trigger a notification on a notification color strip source."""
app: Optional[str] = Field(None, description="App name for color lookup")
color: Optional[str] = Field(None, description="Hex color override (#RRGGBB)")
app: str | None = Field(None, description="App name for color lookup")
color: str | None = Field(None, description="Hex color override (#RRGGBB)")
class CSSCalibrationTestRequest(BaseModel):
+6 -6
View File
@@ -1,7 +1,7 @@
"""Shared schemas used across multiple route modules."""
from datetime import datetime
from typing import Dict, Optional
from typing import Dict
from pydantic import BaseModel, Field
@@ -11,7 +11,7 @@ class ErrorResponse(BaseModel):
error: str = Field(description="Error type")
message: str = Field(description="Error message")
detail: Optional[Dict] = Field(None, description="Additional error details")
detail: Dict | None = Field(None, description="Additional error details")
timestamp: datetime = Field(default_factory=datetime.utcnow, description="Error timestamp")
@@ -19,11 +19,11 @@ class CaptureImage(BaseModel):
"""Captured image with metadata."""
image: str = Field(description="Base64-encoded thumbnail image data")
full_image: Optional[str] = Field(None, description="Base64-encoded full-resolution image data")
full_image: str | None = Field(None, description="Base64-encoded full-resolution image data")
width: int = Field(description="Original image width in pixels")
height: int = Field(description="Original image height in pixels")
thumbnail_width: Optional[int] = Field(None, description="Thumbnail width (if resized)")
thumbnail_height: Optional[int] = Field(None, description="Thumbnail height (if resized)")
thumbnail_width: int | None = Field(None, description="Thumbnail width (if resized)")
thumbnail_height: int | None = Field(None, description="Thumbnail height (if resized)")
class BorderExtraction(BaseModel):
@@ -48,7 +48,7 @@ class TemplateTestResponse(BaseModel):
"""Response from template test."""
full_capture: CaptureImage = Field(description="Full screen capture with thumbnail")
border_extraction: Optional[BorderExtraction] = Field(
border_extraction: BorderExtraction | None = Field(
None, description="Extracted border images (deprecated)"
)
performance: PerformanceMetrics = Field(description="Performance metrics")
+101 -100
View File
@@ -1,7 +1,7 @@
"""Device-related schemas (CRUD, calibration, device state)."""
from datetime import datetime
from typing import Dict, List, Literal, Optional
from typing import Dict, List, Literal
from pydantic import BaseModel, Field
@@ -10,149 +10,150 @@ class DeviceCreate(BaseModel):
"""Request to create/attach an LED device."""
name: str = Field(description="Device name", min_length=1, max_length=100)
url: Optional[str] = Field(
url: str | None = Field(
None,
description="Device URL (e.g., http://192.168.1.100 or COM3). Not required for group devices.",
)
device_type: str = Field(default="wled", description="LED device type (e.g., wled, adalight)")
led_count: Optional[int] = Field(
led_count: int | None = Field(
None, ge=1, le=10000, description="Number of LEDs (required for adalight)"
)
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)")
auto_shutdown: Optional[bool] = Field(
baud_rate: int | None = Field(None, description="Serial baud rate (for adalight devices)")
auto_shutdown: bool | None = Field(
default=None,
description="Turn off device when server stops (defaults to true for adalight)",
)
send_latency_ms: Optional[int] = Field(
send_latency_ms: int | None = Field(
None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)"
)
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
rgbw: bool | None = Field(None, description="RGBW mode (mock devices)")
zone_mode: str | None = Field(None, description="OpenRGB zone mode: combined or separate")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
# DMX (Art-Net / sACN) fields
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: Optional[int] = Field(
None, ge=0, le=32767, description="DMX start universe"
)
dmx_start_channel: Optional[int] = Field(
dmx_protocol: str | None = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: int | None = Field(None, ge=0, le=32767, description="DMX start universe")
dmx_start_channel: int | None = Field(
None, ge=1, le=512, description="DMX start channel (1-512)"
)
# DDP fields
ddp_port: Optional[int] = Field(
ddp_port: int | None = Field(
None, ge=0, le=65535, description="DDP UDP port (0 = protocol default 4048)"
)
ddp_destination_id: Optional[int] = Field(
ddp_destination_id: int | None = Field(
None, ge=0, le=255, description="DDP destination ID (default 1 = display)"
)
ddp_color_order: Optional[int] = Field(
ddp_color_order: int | None = Field(
None,
ge=0,
le=5,
description="DDP color order: 0=GRB 1=RGB 2=BRG 3=RBG 4=BGR 5=GBR (most receivers expect RGB)",
)
# ESP-NOW fields
espnow_peer_mac: Optional[str] = Field(
espnow_peer_mac: str | None = Field(
None, description="ESP-NOW peer MAC address (e.g. AA:BB:CC:DD:EE:FF)"
)
espnow_channel: Optional[int] = Field(
None, ge=1, le=14, description="ESP-NOW WiFi channel (1-14)"
)
espnow_channel: int | None = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel (1-14)")
# Philips Hue fields
hue_username: Optional[str] = Field(None, description="Hue bridge username (from pairing)")
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key (hex)")
hue_entertainment_group_id: Optional[str] = Field(
hue_username: str | None = Field(None, description="Hue bridge username (from pairing)")
hue_client_key: str | None = Field(None, description="Hue entertainment client key (hex)")
hue_entertainment_group_id: str | None = Field(
None, description="Hue entertainment group/zone ID"
)
# Yeelight fields
yeelight_min_interval_ms: Optional[int] = Field(
yeelight_min_interval_ms: int | None = Field(
None,
ge=0,
le=10000,
description="Yeelight client-side rate limit between commands in ms (default 500)",
)
# WiZ fields
wiz_min_interval_ms: Optional[int] = Field(
wiz_min_interval_ms: int | None = Field(
None,
ge=0,
le=10000,
description="WiZ client-side rate limit between commands in ms (default 50)",
)
# LIFX fields
lifx_min_interval_ms: Optional[int] = Field(
lifx_min_interval_ms: int | None = Field(
None,
ge=0,
le=10000,
description="LIFX client-side rate limit between commands in ms (default 50)",
)
# Govee fields
govee_min_interval_ms: Optional[int] = Field(
govee_min_interval_ms: int | None = Field(
None,
ge=0,
le=10000,
description="Govee client-side rate limit between commands in ms (default 50)",
)
# OPC fields
opc_channel: Optional[int] = Field(
opc_channel: int | None = Field(
None,
ge=0,
le=255,
description="OPC channel (0 = broadcast to all channels on the server)",
)
# Nanoleaf fields
nanoleaf_token: Optional[str] = Field(
nanoleaf_token: str | None = Field(
None,
max_length=512,
description="Nanoleaf auth token returned by the pairing handshake",
)
nanoleaf_min_interval_ms: Optional[int] = Field(
nanoleaf_min_interval_ms: int | None = Field(
None,
ge=0,
le=10000,
description="Nanoleaf client-side rate limit between commands in ms (default 100)",
)
# SPI Direct fields
spi_speed_hz: Optional[int] = Field(
spi_speed_hz: int | None = Field(
None, ge=100000, le=4000000, description="SPI clock speed in Hz"
)
spi_led_type: Optional[str] = Field(
spi_led_type: str | None = Field(
None, description="LED chipset: WS2812, WS2812B, WS2811, SK6812, SK6812_RGBW"
)
# Razer Chroma fields
chroma_device_type: Optional[str] = Field(
chroma_device_type: str | None = Field(
None,
description="Chroma peripheral type: keyboard, mouse, mousepad, headset, chromalink, keypad",
)
# SteelSeries GameSense fields
gamesense_device_type: Optional[str] = Field(
gamesense_device_type: str | None = Field(
None, description="GameSense device type: keyboard, mouse, headset, mousepad, indicator"
)
# BLE controller fields
ble_family: Optional[str] = Field(
ble_family: str | None = Field(
None,
description="BLE protocol family: sp110e, triones, zengge, govee",
)
ble_govee_key: Optional[str] = Field(
ble_govee_key: str | None = Field(
None,
description="Govee AES key (hex) — required for encrypted Govee firmware",
)
default_css_processing_template_id: Optional[str] = Field(
# MQTT (multi-broker) field
mqtt_source_id: str | None = Field(
None,
description="MQTT source (broker) ID for device_type=mqtt. Empty = first available broker.",
)
default_css_processing_template_id: str | None = Field(
None, description="Default color strip processing template ID"
)
# Group device fields
group_device_ids: Optional[List[str]] = Field(
group_device_ids: List[str] | None = Field(
None, description="Ordered list of child device IDs (for group device type)"
)
group_mode: Optional[str] = Field(
group_mode: str | None = Field(
None,
description="Group mode: sequence (LEDs concatenated) or independent (each child gets full strip resampled)",
)
# Custom card icon (frontend display only)
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library (e.g. 'mouse', 'motherboard'). Empty/null hides the plate.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the card's channel accent.",
@@ -162,86 +163,83 @@ class DeviceCreate(BaseModel):
class DeviceUpdate(BaseModel):
"""Request to update device information."""
name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100)
url: Optional[str] = Field(None, description="Device URL or serial port")
enabled: Optional[bool] = Field(None, description="Whether device is enabled")
led_count: Optional[int] = Field(
name: str | None = Field(None, description="Device name", min_length=1, max_length=100)
url: str | None = Field(None, description="Device URL or serial port")
enabled: bool | None = Field(None, description="Whether device is enabled")
led_count: int | None = Field(
None,
ge=1,
le=10000,
description="Number of LEDs (for devices with manual_led_count capability)",
)
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)")
auto_shutdown: Optional[bool] = Field(None, description="Turn off device when server stops")
send_latency_ms: Optional[int] = Field(
baud_rate: int | None = Field(None, description="Serial baud rate (for adalight devices)")
auto_shutdown: bool | None = Field(None, description="Turn off device when server stops")
send_latency_ms: int | None = Field(
None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)"
)
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
tags: Optional[List[str]] = None
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: Optional[int] = Field(
None, ge=0, le=32767, description="DMX start universe"
)
dmx_start_channel: Optional[int] = Field(
rgbw: bool | None = Field(None, description="RGBW mode (mock devices)")
zone_mode: str | None = Field(None, description="OpenRGB zone mode: combined or separate")
tags: List[str] | None = None
dmx_protocol: str | None = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: int | None = Field(None, ge=0, le=32767, description="DMX start universe")
dmx_start_channel: int | None = Field(
None, ge=1, le=512, description="DMX start channel (1-512)"
)
ddp_port: Optional[int] = Field(
ddp_port: int | None = Field(
None, ge=0, le=65535, description="DDP UDP port (0 = protocol default 4048)"
)
ddp_destination_id: Optional[int] = Field(None, ge=0, le=255, description="DDP destination ID")
ddp_color_order: Optional[int] = Field(None, ge=0, le=5, description="DDP color order code")
espnow_peer_mac: Optional[str] = Field(None, description="ESP-NOW peer MAC address")
espnow_channel: Optional[int] = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel")
hue_username: Optional[str] = Field(None, description="Hue bridge username")
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key")
hue_entertainment_group_id: Optional[str] = Field(
None, description="Hue entertainment group ID"
)
yeelight_min_interval_ms: Optional[int] = Field(
ddp_destination_id: int | None = Field(None, ge=0, le=255, description="DDP destination ID")
ddp_color_order: int | None = Field(None, ge=0, le=5, description="DDP color order code")
espnow_peer_mac: str | None = Field(None, description="ESP-NOW peer MAC address")
espnow_channel: int | None = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel")
hue_username: str | None = Field(None, description="Hue bridge username")
hue_client_key: str | None = Field(None, description="Hue entertainment client key")
hue_entertainment_group_id: str | None = Field(None, description="Hue entertainment group ID")
yeelight_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Yeelight client-side rate limit in ms"
)
wiz_min_interval_ms: Optional[int] = Field(
wiz_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="WiZ client-side rate limit in ms"
)
lifx_min_interval_ms: Optional[int] = Field(
lifx_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="LIFX client-side rate limit in ms"
)
govee_min_interval_ms: Optional[int] = Field(
govee_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Govee client-side rate limit in ms"
)
opc_channel: Optional[int] = Field(
None, ge=0, le=255, description="OPC channel (0 = broadcast)"
)
nanoleaf_token: Optional[str] = Field(None, max_length=512, description="Nanoleaf auth token")
nanoleaf_min_interval_ms: Optional[int] = Field(
opc_channel: int | None = Field(None, ge=0, le=255, description="OPC channel (0 = broadcast)")
nanoleaf_token: str | None = Field(None, max_length=512, description="Nanoleaf auth token")
nanoleaf_min_interval_ms: int | None = Field(
None, ge=0, le=10000, description="Nanoleaf client-side rate limit in ms"
)
spi_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed")
spi_led_type: Optional[str] = Field(None, description="LED chipset type")
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type")
gamesense_device_type: Optional[str] = Field(None, description="GameSense device type")
ble_family: Optional[str] = Field(
spi_speed_hz: int | None = Field(None, ge=100000, le=4000000, description="SPI clock speed")
spi_led_type: str | None = Field(None, description="LED chipset type")
chroma_device_type: str | None = Field(None, description="Chroma peripheral type")
gamesense_device_type: str | None = Field(None, description="GameSense device type")
ble_family: str | None = Field(
None, description="BLE protocol family: sp110e, triones, zengge, govee"
)
ble_govee_key: Optional[str] = Field(
ble_govee_key: str | None = Field(
None, description="Govee AES key (hex) — required for encrypted Govee firmware"
)
default_css_processing_template_id: Optional[str] = Field(
mqtt_source_id: str | None = Field(
None, description="MQTT source (broker) ID for device_type=mqtt"
)
default_css_processing_template_id: str | None = Field(
None, description="Default color strip processing template ID"
)
# Group device fields
group_device_ids: Optional[List[str]] = Field(
group_device_ids: List[str] | None = Field(
None, description="Ordered list of child device IDs (for group device type)"
)
group_mode: Optional[str] = Field(None, description="Group mode: sequence or independent")
group_mode: str | None = Field(None, description="Group mode: sequence or independent")
# Custom card icon
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -294,7 +292,7 @@ class Calibration(BaseModel):
description="Calibration mode: simple (4-edge) or advanced (multi-source lines)",
)
# Advanced mode: ordered list of lines
lines: Optional[List[CalibrationLineSchema]] = Field(
lines: List[CalibrationLineSchema] | None = Field(
default=None, description="Line list for advanced mode (ignored in simple mode)"
)
# Simple mode fields
@@ -388,7 +386,7 @@ class DeviceResponse(BaseModel):
device_type: str = Field(default="wled", description="LED device type")
led_count: int = Field(description="Total number of LEDs")
enabled: bool = Field(description="Whether device is enabled")
baud_rate: Optional[int] = Field(None, description="Serial baud rate")
baud_rate: int | None = Field(None, description="Serial baud rate")
auto_shutdown: bool = Field(
default=False, description="Restore device to idle state when targets stop"
)
@@ -446,6 +444,9 @@ class DeviceResponse(BaseModel):
ble_govee_key: str = Field(
default="", description="Govee AES key (hex) — required for encrypted Govee firmware"
)
mqtt_source_id: str = Field(
default="", description="MQTT source (broker) ID for device_type=mqtt"
)
default_css_processing_template_id: str = Field(
default="", description="Default color strip processing template ID"
)
@@ -473,19 +474,19 @@ class DeviceStateResponse(BaseModel):
device_id: str = Field(description="Device ID")
device_type: str = Field(default="wled", description="LED device type")
device_online: bool = Field(default=False, description="Whether device is reachable")
device_latency_ms: Optional[float] = Field(None, description="Health check latency in ms")
device_name: Optional[str] = Field(None, description="Device name reported by firmware")
device_version: Optional[str] = Field(None, description="Firmware version")
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: Optional[str] = Field(
device_latency_ms: float | None = Field(None, description="Health check latency in ms")
device_name: str | None = Field(None, description="Device name reported by firmware")
device_version: str | None = Field(None, description="Firmware version")
device_led_count: int | None = Field(None, description="LED count reported by device")
device_rgbw: bool | None = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: str | None = Field(
None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)"
)
device_fps: Optional[int] = Field(
device_fps: int | None = Field(
None, description="Device-reported FPS (WLED internal refresh rate)"
)
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error")
device_last_checked: datetime | None = Field(None, description="Last health check time")
device_error: str | None = Field(None, description="Last health check error")
test_mode: bool = Field(default=False, description="Whether calibration test mode is active")
test_mode_edges: List[str] = Field(
default_factory=list, description="Currently lit edges in test mode"
@@ -500,9 +501,9 @@ class DiscoveredDeviceResponse(BaseModel):
device_type: str = Field(default="wled", description="Device type")
ip: str = Field(description="IP address")
mac: str = Field(default="", description="MAC address")
led_count: Optional[int] = Field(None, description="LED count (if reachable)")
version: Optional[str] = Field(None, description="Firmware version")
ble_family: Optional[str] = Field(
led_count: int | None = Field(None, description="LED count (if reachable)")
version: str | None = Field(None, description="Firmware version")
ble_family: str | None = Field(
None, description="Detected BLE protocol family (sp110e/triones/zengge/govee)"
)
already_added: bool = Field(
+3 -3
View File
@@ -1,6 +1,6 @@
"""Filter-related schemas."""
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
from pydantic import BaseModel, Field
@@ -22,10 +22,10 @@ class FilterOptionDefSchema(BaseModel):
min_value: Any = Field(description="Minimum value")
max_value: Any = Field(description="Maximum value")
step: Any = Field(description="Step increment")
choices: Optional[List[Dict[str, str]]] = Field(
choices: List[Dict[str, str]] | None = Field(
default=None, description="Available choices for select type"
)
max_length: Optional[int] = Field(
max_length: int | None = Field(
default=None, description="Maximum string length for string type"
)
@@ -1,11 +1,10 @@
"""Pydantic schemas for game integration API endpoints."""
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
from pydantic import BaseModel, Field
# ── Event Mapping ──────────────────────────────────────────────────────────
@@ -40,14 +39,14 @@ class GameIntegrationCreate(BaseModel):
event_mappings: List[EventMappingSchema] = Field(
default_factory=list, description="Event-to-effect mappings"
)
description: Optional[str] = Field(None, description="Integration description", max_length=500)
description: str | None = Field(None, description="Integration description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
@@ -57,21 +56,21 @@ class GameIntegrationCreate(BaseModel):
class GameIntegrationUpdate(BaseModel):
"""Request to update a game integration config."""
name: Optional[str] = Field(None, description="Integration name", min_length=1, max_length=100)
adapter_type: Optional[str] = Field(None, description="Adapter type identifier", min_length=1)
enabled: Optional[bool] = Field(None, description="Whether integration is active")
adapter_config: Optional[Dict[str, Any]] = Field(None, description="Adapter-specific settings")
event_mappings: Optional[List[EventMappingSchema]] = Field(
name: str | None = Field(None, description="Integration name", min_length=1, max_length=100)
adapter_type: str | None = Field(None, description="Adapter type identifier", min_length=1)
enabled: bool | None = Field(None, description="Whether integration is active")
adapter_config: Dict[str, Any] | None = Field(None, description="Adapter-specific settings")
event_mappings: List[EventMappingSchema] | None = Field(
None, description="Event-to-effect mappings"
)
description: Optional[str] = Field(None, description="Integration description", max_length=500)
tags: Optional[List[str]] = Field(None, description="User-defined tags")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Integration description", max_length=500)
tags: List[str] | None = Field(None, description="User-defined tags")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -89,14 +88,14 @@ class GameIntegrationResponse(BaseModel):
event_mappings: List[EventMappingSchema] = Field(description="Event-to-effect mappings")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Integration description")
description: str | None = Field(None, description="Integration description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
@@ -158,7 +157,7 @@ class GameIntegrationStatusResponse(BaseModel):
integration_id: str = Field(description="Integration ID")
enabled: bool = Field(description="Whether integration is active")
connected: bool = Field(description="Whether adapter is currently receiving data")
last_event_time: Optional[float] = Field(None, description="Monotonic timestamp of last event")
last_event_time: float | None = Field(None, description="Monotonic timestamp of last event")
event_count: int = Field(default=0, description="Total events received")
event_counts_by_type: Dict[str, int] = Field(
default_factory=dict, description="Event counts per event type"
+13 -13
View File
@@ -1,7 +1,7 @@
"""Gradient schemas (CRUD)."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -18,14 +18,14 @@ class GradientCreate(BaseModel):
name: str = Field(description="Gradient name", min_length=1, max_length=100)
stops: List[GradientStopSchema] = Field(description="Color stops", min_length=2)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -35,16 +35,16 @@ class GradientCreate(BaseModel):
class GradientUpdate(BaseModel):
"""Request to update a gradient."""
name: Optional[str] = Field(None, description="Gradient name", min_length=1, max_length=100)
stops: Optional[List[GradientStopSchema]] = Field(None, description="Color stops", min_length=2)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Gradient name", min_length=1, max_length=100)
stops: List[GradientStopSchema] | None = Field(None, description="Color stops", min_length=2)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -58,16 +58,16 @@ class GradientResponse(BaseModel):
name: str = Field(description="Gradient name")
stops: List[GradientStopSchema] = Field(description="Color stops")
is_builtin: bool = Field(description="Whether this is a built-in gradient")
description: Optional[str] = Field(None, description="Description")
description: str | None = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -1,7 +1,7 @@
"""Home Assistant source schemas (CRUD + test + entities)."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -16,14 +16,14 @@ class HomeAssistantSourceCreate(BaseModel):
entity_filters: List[str] = Field(
default_factory=list, description="Entity ID filter patterns (e.g. ['sensor.*'])"
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
@@ -33,19 +33,19 @@ class HomeAssistantSourceCreate(BaseModel):
class HomeAssistantSourceUpdate(BaseModel):
"""Request to update a Home Assistant source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
host: Optional[str] = Field(None, description="HA host:port", min_length=1)
token: Optional[str] = Field(None, description="Long-Lived Access Token", min_length=1)
use_ssl: Optional[bool] = Field(None, description="Use wss://")
entity_filters: Optional[List[str]] = Field(None, description="Entity ID filter patterns")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
host: str | None = Field(None, description="HA host:port", min_length=1)
token: str | None = Field(None, description="Long-Lived Access Token", min_length=1)
use_ssl: bool | None = Field(None, description="Use wss://")
entity_filters: List[str] | None = Field(None, description="Entity ID filter patterns")
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -62,21 +62,21 @@ class HomeAssistantSourceResponse(BaseModel):
entity_filters: List[str] = Field(default_factory=list, description="Entity filter patterns")
connected: bool = Field(default=False, description="Whether the WebSocket connection is active")
entity_count: int = Field(default=0, description="Number of cached entities")
description: Optional[str] = Field(None, description="Description")
description: str | None = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
token: Optional[str] = Field(
token: str | None = Field(
None,
description=(
"Long-Lived Access Token. Redacted as '***' unless the request "
@@ -112,9 +112,9 @@ class HomeAssistantTestResponse(BaseModel):
"""Connection test result."""
success: bool = Field(description="Whether connection and auth succeeded")
ha_version: Optional[str] = Field(None, description="Home Assistant version")
ha_version: str | None = Field(None, description="Home Assistant version")
entity_count: int = Field(default=0, description="Number of entities found")
error: Optional[str] = Field(None, description="Error message if connection failed")
error: str | None = Field(None, description="Error message if connection failed")
class HomeAssistantConnectionStatus(BaseModel):
@@ -2,12 +2,11 @@
import re
from datetime import datetime
from typing import Any, Dict, List, Literal, Optional
from typing import Any, Dict, List, Literal
from urllib.parse import urlparse
from pydantic import BaseModel, Field, field_validator
# RFC 7230 token chars for header names + reject any control character in values.
_HEADER_NAME_RE = re.compile(r"^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$")
_HEADER_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]")
@@ -64,10 +63,10 @@ class HTTPEndpointCreate(BaseModel):
)
headers: Dict[str, str] = Field(default_factory=dict)
timeout_s: float = Field(default=10.0, gt=0)
description: Optional[str] = Field(None, max_length=500)
description: str | None = Field(None, max_length=500)
tags: List[str] = Field(default_factory=list)
icon: Optional[str] = Field(None, max_length=64)
icon_color: Optional[str] = Field(None, max_length=32)
icon: str | None = Field(None, max_length=64)
icon_color: str | None = Field(None, max_length=32)
@field_validator("headers")
@classmethod
@@ -88,16 +87,16 @@ class HTTPEndpointUpdate(BaseModel):
field (or send ``null``) to keep it.
"""
name: Optional[str] = Field(None, min_length=1, max_length=100)
url: Optional[str] = Field(None, min_length=1)
method: Optional[Literal["GET", "HEAD"]] = None
auth_token: Optional[str] = Field(None, description="null = keep existing; '' = clear.")
headers: Optional[Dict[str, str]] = None
timeout_s: Optional[float] = Field(None, gt=0)
description: Optional[str] = Field(None, max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(None, max_length=64)
icon_color: Optional[str] = Field(None, max_length=32)
name: str | None = Field(None, min_length=1, max_length=100)
url: str | None = Field(None, min_length=1)
method: Literal["GET", "HEAD"] | None = None
auth_token: str | None = Field(None, description="null = keep existing; '' = clear.")
headers: Dict[str, str] | None = None
timeout_s: float | None = Field(None, gt=0)
description: str | None = Field(None, max_length=500)
tags: List[str] | None = None
icon: str | None = Field(None, max_length=64)
icon_color: str | None = Field(None, max_length=32)
@field_validator("headers")
@classmethod
@@ -125,10 +124,10 @@ class HTTPEndpointResponse(BaseModel):
auth_token_set: bool = False
headers: Dict[str, str] = Field(default_factory=dict)
timeout_s: float
description: Optional[str] = None
description: str | None = None
tags: List[str] = Field(default_factory=list)
icon: Optional[str] = Field(None, max_length=64)
icon_color: Optional[str] = Field(None, max_length=32)
icon: str | None = Field(None, max_length=64)
icon_color: str | None = Field(None, max_length=32)
created_at: datetime
updated_at: datetime
@@ -160,7 +159,7 @@ class HTTPTestRequest(BaseModel):
class HTTPTestResponse(BaseModel):
success: bool
status_code: Optional[int] = None
body_preview: Optional[str] = Field(None, description="First 500 chars of the body")
status_code: int | None = None
body_preview: str | None = Field(None, description="First 500 chars of the body")
body_json: Any = None
error: Optional[str] = None
error: str | None = None
+19 -19
View File
@@ -1,7 +1,7 @@
"""MQTT source schemas (CRUD + test + status)."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -16,14 +16,14 @@ class MQTTSourceCreate(BaseModel):
password: str = Field(default="", description="Broker password (optional)")
client_id: str = Field(default="ledgrab", description="MQTT client ID")
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
@@ -33,21 +33,21 @@ class MQTTSourceCreate(BaseModel):
class MQTTSourceUpdate(BaseModel):
"""Request to update an MQTT source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
broker_host: Optional[str] = Field(None, description="MQTT broker hostname or IP", min_length=1)
broker_port: Optional[int] = Field(None, description="MQTT broker port", ge=1, le=65535)
username: Optional[str] = Field(None, description="Broker username")
password: Optional[str] = Field(None, description="Broker password")
client_id: Optional[str] = Field(None, description="MQTT client ID")
base_topic: Optional[str] = Field(None, description="Base topic prefix")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
broker_host: str | None = Field(None, description="MQTT broker hostname or IP", min_length=1)
broker_port: int | None = Field(None, description="MQTT broker port", ge=1, le=65535)
username: str | None = Field(None, description="Broker username")
password: str | None = Field(None, description="Broker password")
client_id: str | None = Field(None, description="MQTT client ID")
base_topic: str | None = Field(None, description="Base topic prefix")
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -66,14 +66,14 @@ class MQTTSourceResponse(BaseModel):
client_id: str = Field(description="MQTT client ID")
base_topic: str = Field(description="Base topic prefix")
connected: bool = Field(default=False, description="Whether the broker connection is active")
description: Optional[str] = Field(None, description="Description")
description: str | None = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
@@ -93,7 +93,7 @@ class MQTTTestResponse(BaseModel):
"""Connection test result."""
success: bool = Field(description="Whether broker connection succeeded")
error: Optional[str] = Field(None, description="Error message if connection failed")
error: str | None = Field(None, description="Error message if connection failed")
class MQTTConnectionStatus(BaseModel):
+118 -138
View File
@@ -1,7 +1,7 @@
"""Output target schemas — discriminated unions per target type."""
from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal, Optional, Union
from typing import Annotated, Any, Dict, List, Literal
from pydantic import BaseModel, Discriminator, Field, Tag
@@ -11,7 +11,7 @@ DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
# BindableFloat — accepts plain number OR {value, source_id} dict
# ---------------------------------------------------------------------------
BindableFloatInput = Union[float, int, Dict[str, Any]]
BindableFloatInput = float | int | Dict[str, Any]
"""API input type: a plain number (static) or {"value": float, "source_id": str}."""
@@ -38,7 +38,7 @@ class HALightMappingSchema(BaseModel):
entity_id: str = Field(description="HA light entity ID (e.g. 'light.living_room')")
led_start: int = Field(default=0, ge=0, description="Start LED index (0-based)")
led_end: int = Field(default=-1, description="End LED index (-1 = last)")
brightness_scale: Optional[BindableFloatInput] = Field(
brightness_scale: BindableFloatInput | None = Field(
default=1.0, description="Brightness multiplier (bindable)"
)
@@ -52,7 +52,7 @@ class Z2MLightMappingSchema(BaseModel):
)
led_start: int = Field(default=0, ge=0, description="Start LED index (0-based)")
led_end: int = Field(default=-1, description="End LED index (-1 = last)")
brightness_scale: Optional[BindableFloatInput] = Field(
brightness_scale: BindableFloatInput | None = Field(
default=1.0, description="Brightness multiplier (bindable)"
)
@@ -67,7 +67,7 @@ class _OutputTargetResponseBase(BaseModel):
id: str = Field(description="Target ID")
name: str = Field(description="Target name")
description: Optional[str] = Field(None, description="Description")
description: str | None = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: str = Field(default="", description="Custom icon id from the curated icon library")
icon_color: str = Field(default="", description="Optional CSS color override for the icon")
@@ -79,13 +79,13 @@ class LedOutputTargetResponse(_OutputTargetResponseBase):
target_type: Literal["led"] = "led"
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable)")
brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
fps: BindableFloatInput | None = Field(None, description="Target send FPS (bindable)")
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)"
)
adaptive_fps: bool = Field(
@@ -110,20 +110,20 @@ class HALightOutputTargetResponse(_OutputTargetResponseBase):
description="Colour value source ID (used when source_kind='color_vs'); "
"must reference a value source whose return_type='color'.",
)
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
ha_light_mappings: List[HALightMappingSchema] | None = Field(
None, description="LED-to-light mappings"
)
update_rate: Optional[BindableFloatInput] = Field(
update_rate: BindableFloatInput | None = Field(
None, description="Service call rate Hz (bindable)"
)
transition: Optional[BindableFloatInput] = Field(
transition: BindableFloatInput | None = Field(
None, description="HA transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
color_tolerance: BindableFloatInput | None = Field(
None, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)"
)
stop_action: Literal["none", "turn_off", "restore"] = Field(
@@ -151,24 +151,24 @@ class Z2MLightOutputTargetResponse(_OutputTargetResponseBase):
default="",
description="Colour value source ID (used when source_kind='color_vs').",
)
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
z2m_light_mappings: Optional[List[Z2MLightMappingSchema]] = Field(
brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
z2m_light_mappings: List[Z2MLightMappingSchema] | None = Field(
None, description="LED-to-bulb mappings (by Z2M friendly_name)"
)
base_topic: str = Field(
default="zigbee2mqtt",
description="Z2M MQTT base topic prefix (override if your Z2M instance is non-default).",
)
update_rate: Optional[BindableFloatInput] = Field(
update_rate: BindableFloatInput | None = Field(
None, description="Publish rate Hz (bindable; 0.5-10)"
)
transition: Optional[BindableFloatInput] = Field(
transition: BindableFloatInput | None = Field(
None, description="Z2M transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
color_tolerance: BindableFloatInput | None = Field(
None, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
default=0, description="Min brightness threshold (bindable, 0=disabled)"
)
stop_action: Literal["none", "turn_off"] = Field(
@@ -179,11 +179,9 @@ class Z2MLightOutputTargetResponse(_OutputTargetResponseBase):
OutputTargetResponse = Annotated[
Union[
Annotated[LedOutputTargetResponse, Tag("led")],
Annotated[HALightOutputTargetResponse, Tag("ha_light")],
Annotated[Z2MLightOutputTargetResponse, Tag("z2m_light")],
],
Annotated[LedOutputTargetResponse, Tag("led")]
| Annotated[HALightOutputTargetResponse, Tag("ha_light")]
| Annotated[Z2MLightOutputTargetResponse, Tag("z2m_light")],
Discriminator("target_type"),
]
@@ -196,12 +194,12 @@ class _OutputTargetCreateBase(BaseModel):
"""Shared fields for all output target create requests."""
name: str = Field(description="Target name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None, max_length=64, description="Custom icon id from the curated icon library"
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None, max_length=32, description="Optional CSS color override for the icon"
)
@@ -210,10 +208,8 @@ class LedOutputTargetCreate(_OutputTargetCreateBase):
target_type: Literal["led"] = "led"
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness (bindable)"
)
fps: Optional[BindableFloatInput] = Field(
brightness: BindableFloatInput | None = Field(default=1.0, description="Brightness (bindable)")
fps: BindableFloatInput | None = Field(
default=30, description="Target send FPS (bindable, 1-90)"
)
keepalive_interval: float = Field(
@@ -228,7 +224,7 @@ class LedOutputTargetCreate(_OutputTargetCreateBase):
ge=5,
le=600,
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
)
@@ -257,22 +253,20 @@ class HALightOutputTargetCreate(_OutputTargetCreateBase):
default="",
description="Colour value source ID (used when source_kind='color_vs').",
)
brightness: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness (bindable)"
)
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
brightness: BindableFloatInput | None = Field(default=1.0, description="Brightness (bindable)")
ha_light_mappings: List[HALightMappingSchema] | None = Field(
None, description="LED-to-light mappings"
)
update_rate: Optional[BindableFloatInput] = Field(
update_rate: BindableFloatInput | None = Field(
default=2.0, description="Service call rate in Hz (bindable)"
)
transition: Optional[BindableFloatInput] = Field(
transition: BindableFloatInput | None = Field(
default=0.5, description="HA transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
color_tolerance: BindableFloatInput | None = Field(
default=5, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
)
@@ -299,10 +293,8 @@ class Z2MLightOutputTargetCreate(_OutputTargetCreateBase):
default="",
description="Colour value source ID (used when source_kind='color_vs').",
)
brightness: Optional[BindableFloatInput] = Field(
default=1.0, description="Brightness (bindable)"
)
z2m_light_mappings: Optional[List[Z2MLightMappingSchema]] = Field(
brightness: BindableFloatInput | None = Field(default=1.0, description="Brightness (bindable)")
z2m_light_mappings: List[Z2MLightMappingSchema] | None = Field(
None, description="LED-to-bulb mappings (by Z2M friendly_name)"
)
base_topic: str = Field(
@@ -310,16 +302,16 @@ class Z2MLightOutputTargetCreate(_OutputTargetCreateBase):
max_length=128,
description="Z2M MQTT base topic prefix.",
)
update_rate: Optional[BindableFloatInput] = Field(
update_rate: BindableFloatInput | None = Field(
default=5.0, description="Publish rate in Hz (bindable; 0.5-10)"
)
transition: Optional[BindableFloatInput] = Field(
transition: BindableFloatInput | None = Field(
default=0.3, description="Z2M transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
color_tolerance: BindableFloatInput | None = Field(
default=5, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
default=0,
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
)
@@ -330,11 +322,9 @@ class Z2MLightOutputTargetCreate(_OutputTargetCreateBase):
OutputTargetCreate = Annotated[
Union[
Annotated[LedOutputTargetCreate, Tag("led")],
Annotated[HALightOutputTargetCreate, Tag("ha_light")],
Annotated[Z2MLightOutputTargetCreate, Tag("z2m_light")],
],
Annotated[LedOutputTargetCreate, Tag("led")]
| Annotated[HALightOutputTargetCreate, Tag("ha_light")]
| Annotated[Z2MLightOutputTargetCreate, Tag("z2m_light")],
Discriminator("target_type"),
]
@@ -346,15 +336,15 @@ OutputTargetCreate = Annotated[
class _OutputTargetUpdateBase(BaseModel):
"""Shared fields for all output target update requests."""
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Target name", min_length=1, max_length=100)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Custom icon id; pass empty string to clear and inherit from device.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon; empty string clears.",
@@ -363,103 +353,99 @@ class _OutputTargetUpdateBase(BaseModel):
class LedOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["led"] = "led"
device_id: Optional[str] = Field(None, description="LED device ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
fps: Optional[BindableFloatInput] = Field(None, description="Target send FPS (bindable, 1-90)")
keepalive_interval: Optional[float] = Field(
device_id: str | None = Field(None, description="LED device ID")
color_strip_source_id: str | None = Field(None, description="Color strip source ID")
brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
fps: BindableFloatInput | None = Field(None, description="Target send FPS (bindable, 1-90)")
keepalive_interval: float | None = Field(
None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0
)
state_check_interval: Optional[int] = Field(
state_check_interval: int | None = Field(
None, description="Health check interval (5-600s)", ge=5, le=600
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
None, description="Min brightness threshold (bindable, 0=disabled)"
)
adaptive_fps: Optional[bool] = Field(
adaptive_fps: bool | None = Field(
None, description="Auto-reduce FPS when device is unresponsive"
)
protocol: Optional[str] = Field(
protocol: str | None = Field(
None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)"
)
class HALightOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["ha_light"] = "ha_light"
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID")
source_kind: Optional[Literal["css", "color_vs"]] = Field(
ha_source_id: str | None = Field(None, description="Home Assistant source ID")
source_kind: Literal["css", "color_vs"] | None = Field(
None,
description="Colour source kind: 'css' or 'color_vs'.",
)
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
color_value_source_id: Optional[str] = Field(
color_strip_source_id: str | None = Field(None, description="Color strip source ID")
color_value_source_id: str | None = Field(
None,
description="Colour value source ID (used when source_kind='color_vs').",
)
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
ha_light_mappings: List[HALightMappingSchema] | None = Field(
None, description="LED-to-light mappings"
)
update_rate: Optional[BindableFloatInput] = Field(
update_rate: BindableFloatInput | None = Field(
None, description="Service call rate Hz (bindable)"
)
transition: Optional[BindableFloatInput] = Field(
transition: BindableFloatInput | None = Field(
None, description="HA transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
color_tolerance: BindableFloatInput | None = Field(
None, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
None, description="Min brightness threshold (bindable, 0=disabled)"
)
stop_action: Optional[Literal["none", "turn_off", "restore"]] = Field(
stop_action: Literal["none", "turn_off", "restore"] | None = Field(
None, description="Finalization on stop: 'none', 'turn_off', or 'restore'."
)
class Z2MLightOutputTargetUpdate(_OutputTargetUpdateBase):
target_type: Literal["z2m_light"] = "z2m_light"
mqtt_source_id: Optional[str] = Field(
mqtt_source_id: str | None = Field(
None,
description="MQTT source (broker) id. Empty string clears the binding.",
)
source_kind: Optional[Literal["css", "color_vs"]] = Field(
source_kind: Literal["css", "color_vs"] | None = Field(
None, description="Colour source kind: 'css' or 'color_vs'."
)
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
color_value_source_id: Optional[str] = Field(
color_strip_source_id: str | None = Field(None, description="Color strip source ID")
color_value_source_id: str | None = Field(
None, description="Colour value source ID (used when source_kind='color_vs')."
)
brightness: Optional[BindableFloatInput] = Field(None, description="Brightness (bindable)")
z2m_light_mappings: Optional[List[Z2MLightMappingSchema]] = Field(
brightness: BindableFloatInput | None = Field(None, description="Brightness (bindable)")
z2m_light_mappings: List[Z2MLightMappingSchema] | None = Field(
None, description="LED-to-bulb mappings (by Z2M friendly_name)"
)
base_topic: Optional[str] = Field(
None, max_length=128, description="Z2M MQTT base topic prefix."
)
update_rate: Optional[BindableFloatInput] = Field(
base_topic: str | None = Field(None, max_length=128, description="Z2M MQTT base topic prefix.")
update_rate: BindableFloatInput | None = Field(
None, description="Publish rate Hz (bindable; 0.5-10)"
)
transition: Optional[BindableFloatInput] = Field(
transition: BindableFloatInput | None = Field(
None, description="Z2M transition seconds (bindable)"
)
color_tolerance: Optional[BindableFloatInput] = Field(
color_tolerance: BindableFloatInput | None = Field(
None, description="RGB delta tolerance (bindable)"
)
min_brightness_threshold: Optional[BindableFloatInput] = Field(
min_brightness_threshold: BindableFloatInput | None = Field(
None, description="Min brightness threshold (bindable, 0=disabled)"
)
stop_action: Optional[Literal["none", "turn_off"]] = Field(
stop_action: Literal["none", "turn_off"] | None = Field(
None, description="Finalization on stop: 'none' or 'turn_off'."
)
OutputTargetUpdate = Annotated[
Union[
Annotated[LedOutputTargetUpdate, Tag("led")],
Annotated[HALightOutputTargetUpdate, Tag("ha_light")],
Annotated[Z2MLightOutputTargetUpdate, Tag("z2m_light")],
],
Annotated[LedOutputTargetUpdate, Tag("led")]
| Annotated[HALightOutputTargetUpdate, Tag("ha_light")]
| Annotated[Z2MLightOutputTargetUpdate, Tag("z2m_light")],
Discriminator("target_type"),
]
@@ -479,75 +465,69 @@ class TargetProcessingState(BaseModel):
"""Processing state for an output target."""
target_id: str = Field(description="Target ID")
device_id: Optional[str] = Field(None, description="Device ID")
device_id: str | None = Field(None, description="Device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
fps_potential: Optional[float] = Field(
fps_actual: float | None = Field(None, description="Actual FPS achieved")
fps_potential: float | None = Field(
None, description="Potential FPS (processing speed without throttle)"
)
fps_target: Optional[int] = Field(None, description="Target FPS")
fps_capture: Optional[int] = Field(
fps_target: int | None = Field(None, description="Target FPS")
fps_capture: int | None = Field(
None, description="Configured capture-side FPS for the underlying color strip stream"
)
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
frames_keepalive: Optional[int] = Field(
None, description="Keepalive frames sent during standby"
)
fps_current: Optional[int] = Field(None, description="Frames sent in the last second")
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
timing_extract_ms: Optional[float] = Field(
None, description="Border pixel extraction time (ms)"
)
timing_map_leds_ms: Optional[float] = Field(None, description="LED color mapping time (ms)")
timing_smooth_ms: Optional[float] = Field(None, description="Temporal smoothing time (ms)")
timing_total_ms: Optional[float] = Field(
None, description="Total processing time per frame (ms)"
)
timing_audio_read_ms: Optional[float] = Field(None, description="Audio device read time (ms)")
timing_audio_fft_ms: Optional[float] = Field(None, description="Audio FFT analysis time (ms)")
timing_audio_render_ms: Optional[float] = Field(
frames_skipped: int | None = Field(None, description="Frames skipped (no screen change)")
frames_keepalive: int | None = Field(None, description="Keepalive frames sent during standby")
fps_current: int | None = Field(None, description="Frames sent in the last second")
timing_send_ms: float | None = Field(None, description="DDP send time (ms)")
timing_extract_ms: float | None = Field(None, description="Border pixel extraction time (ms)")
timing_map_leds_ms: float | None = Field(None, description="LED color mapping time (ms)")
timing_smooth_ms: float | None = Field(None, description="Temporal smoothing time (ms)")
timing_total_ms: float | None = Field(None, description="Total processing time per frame (ms)")
timing_audio_read_ms: float | None = Field(None, description="Audio device read time (ms)")
timing_audio_fft_ms: float | None = Field(None, description="Audio FFT analysis time (ms)")
timing_audio_render_ms: float | None = Field(
None, description="Audio visualization render time (ms)"
)
display_index: Optional[int] = Field(None, description="Current display index")
display_index: int | None = Field(None, description="Current display index")
overlay_active: bool = Field(
default=False, description="Whether visualization overlay is active"
)
last_update: Optional[datetime] = Field(None, description="Last successful update")
last_update: datetime | None = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors")
device_online: bool = Field(default=False, description="Whether device is reachable")
device_latency_ms: Optional[float] = Field(None, description="Health check latency in ms")
device_name: Optional[str] = Field(None, description="Device name reported by firmware")
device_version: Optional[str] = Field(None, description="Firmware version")
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: Optional[str] = Field(
device_latency_ms: float | None = Field(None, description="Health check latency in ms")
device_name: str | None = Field(None, description="Device name reported by firmware")
device_version: str | None = Field(None, description="Firmware version")
device_led_count: int | None = Field(None, description="LED count reported by device")
device_rgbw: bool | None = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: str | None = Field(
None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)"
)
device_fps: Optional[int] = Field(
device_fps: int | None = Field(
None, description="Device-reported FPS (WLED internal refresh rate)"
)
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error")
device_streaming_reachable: Optional[bool] = Field(
device_last_checked: datetime | None = Field(None, description="Last health check time")
device_error: str | None = Field(None, description="Last health check error")
device_streaming_reachable: bool | None = Field(
None, description="Device reachable during streaming (HTTP probe)"
)
fps_effective: Optional[int] = Field(None, description="Effective FPS after adaptive reduction")
fps_effective: int | None = Field(None, description="Effective FPS after adaptive reduction")
class TargetMetricsResponse(BaseModel):
"""Target metrics response."""
target_id: str = Field(description="Target ID")
device_id: Optional[str] = Field(None, description="Device ID")
device_id: str | None = Field(None, description="Device ID")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS")
fps_target: Optional[int] = Field(None, description="Target FPS")
fps_actual: float | None = Field(None, description="Actual FPS")
fps_target: int | None = Field(None, description="Target FPS")
uptime_seconds: float = Field(description="Processing uptime in seconds")
frames_processed: int = Field(description="Total frames processed")
errors_count: int = Field(description="Total error count")
last_error: Optional[str] = Field(None, description="Last error message")
last_update: Optional[datetime] = Field(None, description="Last update timestamp")
last_error: str | None = Field(None, description="Last error message")
last_update: datetime | None = Field(None, description="Last update timestamp")
class BulkTargetRequest(BaseModel):
@@ -1,7 +1,7 @@
"""Pydantic schemas for pattern template API."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -15,14 +15,14 @@ class PatternTemplateCreate(BaseModel):
rectangles: List[KeyColorRectangleSchema] = Field(
default_factory=list, description="List of named rectangles"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -32,18 +32,18 @@ class PatternTemplateCreate(BaseModel):
class PatternTemplateUpdate(BaseModel):
"""Request to update a pattern template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
rectangles: Optional[List[KeyColorRectangleSchema]] = Field(
name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
rectangles: List[KeyColorRectangleSchema] | None = Field(
None, description="List of named rectangles"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -59,13 +59,13 @@ class PatternTemplateResponse(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -1,7 +1,7 @@
"""Picture source schemas — discriminated unions per stream type."""
from datetime import datetime
from typing import Annotated, List, Literal, Optional, Union
from typing import Annotated, List, Literal
from pydantic import BaseModel, Discriminator, Field, Tag
@@ -15,16 +15,16 @@ class _PictureSourceResponseBase(BaseModel):
id: str = Field(description="Stream ID")
name: str = Field(description="Stream name")
description: Optional[str] = Field(None, description="Stream description")
description: str | None = Field(None, description="Stream description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -46,28 +46,26 @@ class ProcessedPictureSourceResponse(_PictureSourceResponseBase):
class StaticImagePictureSourceResponse(_PictureSourceResponseBase):
stream_type: Literal["static_image"] = "static_image"
image_asset_id: Optional[str] = Field(None, description="Image asset ID")
image_asset_id: str | None = Field(None, description="Image asset ID")
class VideoPictureSourceResponse(_PictureSourceResponseBase):
stream_type: Literal["video"] = "video"
video_asset_id: Optional[str] = Field(None, description="Video asset ID")
video_asset_id: str | None = Field(None, description="Video asset ID")
loop: bool = Field(True, description="Loop video playback")
playback_speed: float = Field(1.0, description="Playback speed multiplier")
start_time: Optional[float] = Field(None, description="Trim start time in seconds")
end_time: Optional[float] = Field(None, description="Trim end time in seconds")
resolution_limit: Optional[int] = Field(None, description="Max width for decode")
clock_id: Optional[str] = Field(None, description="Sync clock ID")
start_time: float | None = Field(None, description="Trim start time in seconds")
end_time: float | None = Field(None, description="Trim end time in seconds")
resolution_limit: int | None = Field(None, description="Max width for decode")
clock_id: str | None = Field(None, description="Sync clock ID")
target_fps: int = Field(30, description="Target FPS")
PictureSourceResponse = Annotated[
Union[
Annotated[RawPictureSourceResponse, Tag("raw")],
Annotated[ProcessedPictureSourceResponse, Tag("processed")],
Annotated[StaticImagePictureSourceResponse, Tag("static_image")],
Annotated[VideoPictureSourceResponse, Tag("video")],
],
Annotated[RawPictureSourceResponse, Tag("raw")]
| Annotated[ProcessedPictureSourceResponse, Tag("processed")]
| Annotated[StaticImagePictureSourceResponse, Tag("static_image")]
| Annotated[VideoPictureSourceResponse, Tag("video")],
Discriminator("stream_type"),
]
@@ -80,14 +78,14 @@ class _PictureSourceCreateBase(BaseModel):
"""Shared fields for all picture source create requests."""
name: str = Field(description="Stream name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Stream description", max_length=500)
description: str | None = Field(None, description="Stream description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -117,22 +115,20 @@ class VideoPictureSourceCreate(_PictureSourceCreateBase):
video_asset_id: str = Field(description="Video asset ID")
loop: bool = Field(True, description="Loop video playback")
playback_speed: float = Field(1.0, description="Playback speed multiplier", ge=0.1, le=10.0)
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: Optional[int] = Field(
start_time: float | None = Field(None, description="Trim start time in seconds", ge=0)
end_time: float | None = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: int | None = Field(
None, description="Max width in pixels for decode downscale", ge=64, le=7680
)
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
clock_id: str | None = Field(None, description="Sync clock ID for frame-accurate timing")
target_fps: int = Field(30, description="Target FPS", ge=1, le=90)
PictureSourceCreate = Annotated[
Union[
Annotated[RawPictureSourceCreate, Tag("raw")],
Annotated[ProcessedPictureSourceCreate, Tag("processed")],
Annotated[StaticImagePictureSourceCreate, Tag("static_image")],
Annotated[VideoPictureSourceCreate, Tag("video")],
],
Annotated[RawPictureSourceCreate, Tag("raw")]
| Annotated[ProcessedPictureSourceCreate, Tag("processed")]
| Annotated[StaticImagePictureSourceCreate, Tag("static_image")]
| Annotated[VideoPictureSourceCreate, Tag("video")],
Discriminator("stream_type"),
]
@@ -144,15 +140,15 @@ PictureSourceCreate = Annotated[
class _PictureSourceUpdateBase(BaseModel):
"""Shared fields for all picture source update requests."""
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Stream name", min_length=1, max_length=100)
description: str | None = Field(None, description="Stream description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -161,47 +157,43 @@ class _PictureSourceUpdateBase(BaseModel):
class RawPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["raw"] = "raw"
display_index: Optional[int] = Field(None, description="Display index", ge=0)
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
display_index: int | None = Field(None, description="Display index", ge=0)
capture_template_id: str | None = Field(None, description="Capture template ID")
target_fps: int | None = Field(None, description="Target FPS", ge=1, le=90)
class ProcessedPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["processed"] = "processed"
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
postprocessing_template_id: Optional[str] = Field(
None, description="Postprocessing template ID"
)
source_stream_id: str | None = Field(None, description="Source stream ID")
postprocessing_template_id: str | None = Field(None, description="Postprocessing template ID")
class StaticImagePictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["static_image"] = "static_image"
image_asset_id: Optional[str] = Field(None, description="Image asset ID")
image_asset_id: str | None = Field(None, description="Image asset ID")
class VideoPictureSourceUpdate(_PictureSourceUpdateBase):
stream_type: Literal["video"] = "video"
video_asset_id: Optional[str] = Field(None, description="Video asset ID")
loop: Optional[bool] = Field(None, description="Loop video playback")
playback_speed: Optional[float] = Field(
video_asset_id: str | None = Field(None, description="Video asset ID")
loop: bool | None = Field(None, description="Loop video playback")
playback_speed: float | None = Field(
None, description="Playback speed multiplier", ge=0.1, le=10.0
)
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: Optional[int] = Field(
start_time: float | None = Field(None, description="Trim start time in seconds", ge=0)
end_time: float | None = Field(None, description="Trim end time in seconds", ge=0)
resolution_limit: int | None = Field(
None, description="Max width in pixels for decode downscale", ge=64, le=7680
)
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
clock_id: str | None = Field(None, description="Sync clock ID for frame-accurate timing")
target_fps: int | None = Field(None, description="Target FPS", ge=1, le=90)
PictureSourceUpdate = Annotated[
Union[
Annotated[RawPictureSourceUpdate, Tag("raw")],
Annotated[ProcessedPictureSourceUpdate, Tag("processed")],
Annotated[StaticImagePictureSourceUpdate, Tag("static_image")],
Annotated[VideoPictureSourceUpdate, Tag("video")],
],
Annotated[RawPictureSourceUpdate, Tag("raw")]
| Annotated[ProcessedPictureSourceUpdate, Tag("processed")]
| Annotated[StaticImagePictureSourceUpdate, Tag("static_image")]
| Annotated[VideoPictureSourceUpdate, Tag("video")],
Discriminator("stream_type"),
]
@@ -246,7 +238,7 @@ class ImageValidateResponse(BaseModel):
"""Response from image validation."""
valid: bool = Field(description="Whether the image source is accessible and valid")
width: Optional[int] = Field(None, description="Image width in pixels")
height: Optional[int] = Field(None, description="Image height in pixels")
preview: Optional[str] = Field(None, description="Base64-encoded JPEG thumbnail")
error: Optional[str] = Field(None, description="Error message if invalid")
width: int | None = Field(None, description="Image width in pixels")
height: int | None = Field(None, description="Image height in pixels")
preview: str | None = Field(None, description="Base64-encoded JPEG thumbnail")
error: str | None = Field(None, description="Error message if invalid")
@@ -1,7 +1,7 @@
"""Postprocessing template schemas."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -15,14 +15,14 @@ class PostprocessingTemplateCreate(BaseModel):
filters: List[FilterInstanceSchema] = Field(
default_factory=list, description="Ordered list of filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -32,18 +32,18 @@ class PostprocessingTemplateCreate(BaseModel):
class PostprocessingTemplateUpdate(BaseModel):
"""Request to update a postprocessing template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
filters: Optional[List[FilterInstanceSchema]] = Field(
name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
filters: List[FilterInstanceSchema] | None = Field(
None, description="Ordered list of filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -59,13 +59,13 @@ class PostprocessingTemplateResponse(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
+13 -15
View File
@@ -1,7 +1,7 @@
"""Scene preset API schemas."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -19,16 +19,14 @@ class ScenePresetCreate(BaseModel):
name: str = Field(description="Preset name", min_length=1, max_length=100)
description: str = Field(default="", max_length=500)
target_ids: Optional[List[str]] = Field(
None, description="Target IDs to capture (all if omitted)"
)
target_ids: List[str] | None = Field(None, description="Target IDs to capture (all if omitted)")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -38,20 +36,20 @@ class ScenePresetCreate(BaseModel):
class ScenePresetUpdate(BaseModel):
"""Update scene preset metadata and optionally change which targets are included."""
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
order: Optional[int] = None
target_ids: Optional[List[str]] = Field(
name: str | None = Field(None, min_length=1, max_length=100)
description: str | None = Field(None, max_length=500)
order: int | None = None
target_ids: List[str] | None = Field(
None,
description="Update target list: keep state for existing, capture fresh for new, drop removed",
)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -67,12 +65,12 @@ class ScenePresetResponse(BaseModel):
targets: List[TargetSnapshotSchema]
order: int
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
+13 -13
View File
@@ -1,7 +1,7 @@
"""Sync clock schemas (CRUD + control)."""
from datetime import datetime
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field
@@ -11,14 +11,14 @@ class SyncClockCreate(BaseModel):
name: str = Field(description="Clock name", min_length=1, max_length=100)
speed: float = Field(default=1.0, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
@@ -28,16 +28,16 @@ class SyncClockCreate(BaseModel):
class SyncClockUpdate(BaseModel):
"""Request to update a synchronization clock."""
name: Optional[str] = Field(None, description="Clock name", min_length=1, max_length=100)
speed: Optional[float] = Field(None, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Clock name", min_length=1, max_length=100)
speed: float | None = Field(None, description="Speed multiplier (0.110.0)", ge=0.1, le=10.0)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -50,14 +50,14 @@ class SyncClockResponse(BaseModel):
id: str = Field(description="Clock ID")
name: str = Field(description="Clock name")
speed: float = Field(description="Speed multiplier")
description: Optional[str] = Field(None, description="Description")
description: str | None = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
+14 -14
View File
@@ -1,7 +1,7 @@
"""Capture template and engine schemas."""
from datetime import datetime
from typing import Dict, List, Optional
from typing import Dict, List
from pydantic import BaseModel, Field
@@ -12,14 +12,14 @@ class TemplateCreate(BaseModel):
name: str = Field(description="Template name", min_length=1, max_length=100)
engine_type: str = Field(description="Engine type (e.g., 'mss', 'dxcam', 'wgc')", min_length=1)
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -29,17 +29,17 @@ class TemplateCreate(BaseModel):
class TemplateUpdate(BaseModel):
"""Request to update a template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
engine_type: Optional[str] = Field(None, description="Capture engine type (mss, dxcam, wgc)")
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Template name", min_length=1, max_length=100)
engine_type: str | None = Field(None, description="Capture engine type (mss, dxcam, wgc)")
engine_config: Dict | None = Field(None, description="Engine-specific configuration")
description: str | None = Field(None, description="Template description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
@@ -56,11 +56,11 @@ class TemplateResponse(BaseModel):
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
icon: Optional[str] = Field(
description: str | None = Field(None, description="Template description")
icon: str | None = Field(
None, max_length=64, description="Icon id from the curated icon library."
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None, max_length=32, description="Optional CSS color override for the icon."
)
+115 -123
View File
@@ -1,7 +1,7 @@
"""Value source schemas — discriminated unions per source type."""
from datetime import datetime
from typing import Annotated, List, Literal, Optional, Union
from typing import Annotated, List, Literal
from pydantic import BaseModel, Discriminator, Field, Tag
@@ -15,14 +15,14 @@ class _ValueSourceResponseBase(BaseModel):
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
description: Optional[str] = Field(None, description="Description")
description: str | None = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
@@ -100,7 +100,7 @@ class AnimatedColorValueSourceResponse(_ValueSourceResponseBase):
colors: List[List[int]] = Field(description="Color list [[R,G,B], ...]")
speed: float = Field(description="Cycles per minute (ignored when clock_id is set)")
easing: str = Field(description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine")
clock_id: Optional[str] = Field(
clock_id: str | None = Field(
None, description="Optional sync clock ID for shared timing (overrides speed)"
)
@@ -163,22 +163,20 @@ class HTTPValueSourceResponse(_ValueSourceResponseBase):
ValueSourceResponse = Annotated[
Union[
Annotated[StaticValueSourceResponse, Tag("static")],
Annotated[AnimatedValueSourceResponse, Tag("animated")],
Annotated[AudioValueSourceResponse, Tag("audio")],
Annotated[AdaptiveTimeValueSourceResponse, Tag("adaptive_time")],
Annotated[AdaptiveSceneValueSourceResponse, Tag("adaptive_scene")],
Annotated[DaylightValueSourceResponse, Tag("daylight")],
Annotated[StaticColorValueSourceResponse, Tag("static_color")],
Annotated[AnimatedColorValueSourceResponse, Tag("animated_color")],
Annotated[AdaptiveTimeColorValueSourceResponse, Tag("adaptive_time_color")],
Annotated[HAEntityValueSourceResponse, Tag("ha_entity")],
Annotated[GradientMapValueSourceResponse, Tag("gradient_map")],
Annotated[CSSExtractValueSourceResponse, Tag("css_extract")],
Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")],
Annotated[HTTPValueSourceResponse, Tag("http")],
],
Annotated[StaticValueSourceResponse, Tag("static")]
| Annotated[AnimatedValueSourceResponse, Tag("animated")]
| Annotated[AudioValueSourceResponse, Tag("audio")]
| Annotated[AdaptiveTimeValueSourceResponse, Tag("adaptive_time")]
| Annotated[AdaptiveSceneValueSourceResponse, Tag("adaptive_scene")]
| Annotated[DaylightValueSourceResponse, Tag("daylight")]
| Annotated[StaticColorValueSourceResponse, Tag("static_color")]
| Annotated[AnimatedColorValueSourceResponse, Tag("animated_color")]
| Annotated[AdaptiveTimeColorValueSourceResponse, Tag("adaptive_time_color")]
| Annotated[HAEntityValueSourceResponse, Tag("ha_entity")]
| Annotated[GradientMapValueSourceResponse, Tag("gradient_map")]
| Annotated[CSSExtractValueSourceResponse, Tag("css_extract")]
| Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")]
| Annotated[HTTPValueSourceResponse, Tag("http")],
Discriminator("source_type"),
]
@@ -191,14 +189,14 @@ class _ValueSourceCreateBase(BaseModel):
"""Shared fields for all value source create requests."""
name: str = Field(description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
@@ -276,7 +274,7 @@ class AnimatedColorValueSourceCreate(_ValueSourceCreateBase):
easing: str = Field(
"linear", description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine"
)
clock_id: Optional[str] = Field(
clock_id: str | None = Field(
None, description="Optional sync clock ID (overrides speed when set)"
)
@@ -333,22 +331,20 @@ class HTTPValueSourceCreate(_ValueSourceCreateBase):
ValueSourceCreate = Annotated[
Union[
Annotated[StaticValueSourceCreate, Tag("static")],
Annotated[AnimatedValueSourceCreate, Tag("animated")],
Annotated[AudioValueSourceCreate, Tag("audio")],
Annotated[AdaptiveTimeValueSourceCreate, Tag("adaptive_time")],
Annotated[AdaptiveSceneValueSourceCreate, Tag("adaptive_scene")],
Annotated[DaylightValueSourceCreate, Tag("daylight")],
Annotated[StaticColorValueSourceCreate, Tag("static_color")],
Annotated[AnimatedColorValueSourceCreate, Tag("animated_color")],
Annotated[AdaptiveTimeColorValueSourceCreate, Tag("adaptive_time_color")],
Annotated[HAEntityValueSourceCreate, Tag("ha_entity")],
Annotated[GradientMapValueSourceCreate, Tag("gradient_map")],
Annotated[CSSExtractValueSourceCreate, Tag("css_extract")],
Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")],
Annotated[HTTPValueSourceCreate, Tag("http")],
],
Annotated[StaticValueSourceCreate, Tag("static")]
| Annotated[AnimatedValueSourceCreate, Tag("animated")]
| Annotated[AudioValueSourceCreate, Tag("audio")]
| Annotated[AdaptiveTimeValueSourceCreate, Tag("adaptive_time")]
| Annotated[AdaptiveSceneValueSourceCreate, Tag("adaptive_scene")]
| Annotated[DaylightValueSourceCreate, Tag("daylight")]
| Annotated[StaticColorValueSourceCreate, Tag("static_color")]
| Annotated[AnimatedColorValueSourceCreate, Tag("animated_color")]
| Annotated[AdaptiveTimeColorValueSourceCreate, Tag("adaptive_time_color")]
| Annotated[HAEntityValueSourceCreate, Tag("ha_entity")]
| Annotated[GradientMapValueSourceCreate, Tag("gradient_map")]
| Annotated[CSSExtractValueSourceCreate, Tag("css_extract")]
| Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")]
| Annotated[HTTPValueSourceCreate, Tag("http")],
Discriminator("source_type"),
]
@@ -360,15 +356,15 @@ ValueSourceCreate = Annotated[
class _ValueSourceUpdateBase(BaseModel):
"""Shared fields for all value source update requests."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -377,142 +373,138 @@ class _ValueSourceUpdateBase(BaseModel):
class StaticValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["static"] = "static"
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
value: float | None = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
class AnimatedValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["animated"] = "animated"
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
waveform: str | None = Field(None, description="Waveform: sine|triangle|square|sawtooth")
speed: float | None = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
min_value: float | None = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: float | None = Field(None, description="Maximum output", ge=0.0, le=1.0)
class AudioValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["audio"] = "audio"
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
sensitivity: Optional[float] = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing", ge=0.0, le=1.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels")
audio_source_id: str | None = Field(None, description="Mono audio source ID")
mode: str | None = Field(None, description="Audio mode: rms|peak|beat")
sensitivity: float | None = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
smoothing: float | None = Field(None, description="Temporal smoothing", ge=0.0, le=1.0)
min_value: float | None = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: float | None = Field(None, description="Maximum output", ge=0.0, le=1.0)
auto_gain: bool | None = Field(None, description="Auto-normalize audio levels")
class AdaptiveTimeValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_time"] = "adaptive_time"
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
schedule: list | None = Field(None, description="Time-of-day schedule")
min_value: float | None = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: float | None = Field(None, description="Maximum output", ge=0.0, le=1.0)
class AdaptiveSceneValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_scene"] = "adaptive_scene"
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
scene_behavior: Optional[str] = Field(None, description="Scene behavior")
sensitivity: Optional[float] = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing", ge=0.0, le=1.0)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
picture_source_id: str | None = Field(None, description="Picture source ID")
scene_behavior: str | None = Field(None, description="Scene behavior")
sensitivity: float | None = Field(None, description="Gain multiplier", ge=0.1, le=20.0)
smoothing: float | None = Field(None, description="Temporal smoothing", ge=0.0, le=1.0)
min_value: float | None = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: float | None = Field(None, description="Maximum output", ge=0.0, le=1.0)
class DaylightValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["daylight"] = "daylight"
speed: Optional[float] = Field(None, description="Simulation speed", ge=0.1, le=120.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Geographic latitude", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(
None, description="Geographic longitude", ge=-180.0, le=180.0
)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
speed: float | None = Field(None, description="Simulation speed", ge=0.1, le=120.0)
use_real_time: bool | None = Field(None, description="Use wall-clock time")
latitude: float | None = Field(None, description="Geographic latitude", ge=-90.0, le=90.0)
longitude: float | None = Field(None, description="Geographic longitude", ge=-180.0, le=180.0)
min_value: float | None = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: float | None = Field(None, description="Maximum output", ge=0.0, le=1.0)
class StaticColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["static_color"] = "static_color"
color: Optional[List[int]] = Field(None, description="Static RGB color [R,G,B]")
color: List[int] | None = Field(None, description="Static RGB color [R,G,B]")
class AnimatedColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["animated_color"] = "animated_color"
colors: Optional[List[List[int]]] = Field(None, description="Color list [[R,G,B], ...]")
speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
easing: Optional[str] = Field(
colors: List[List[int]] | None = Field(None, description="Color list [[R,G,B], ...]")
speed: float | None = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
easing: str | None = Field(
None, description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine"
)
clock_id: Optional[str] = Field(
clock_id: str | None = Field(
None, description="Optional sync clock ID (empty string clears, null leaves unchanged)"
)
class AdaptiveTimeColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["adaptive_time_color"] = "adaptive_time_color"
schedule: Optional[list] = Field(None, description="Color schedule")
schedule: list | None = Field(None, description="Color schedule")
class HAEntityValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["ha_entity"] = "ha_entity"
ha_source_id: Optional[str] = Field(None, description="Home Assistant source ID")
entity_id: Optional[str] = Field(None, description="HA entity ID")
attribute: Optional[str] = Field(None, description="Attribute name")
min_ha_value: Optional[float] = Field(None, description="Min HA value")
max_ha_value: Optional[float] = Field(None, description="Max HA value")
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
ha_source_id: str | None = Field(None, description="Home Assistant source ID")
entity_id: str | None = Field(None, description="HA entity ID")
attribute: str | None = Field(None, description="Attribute name")
min_ha_value: float | None = Field(None, description="Min HA value")
max_ha_value: float | None = Field(None, description="Max HA value")
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
class GradientMapValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["gradient_map"] = "gradient_map"
value_source_id: Optional[str] = Field(None, description="Input value source ID")
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
easing: Optional[str] = Field(None, description="Interpolation mode")
value_source_id: str | None = Field(None, description="Input value source ID")
gradient_id: str | None = Field(None, description="Gradient entity ID")
easing: str | None = Field(None, description="Interpolation mode")
class CSSExtractValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["css_extract"] = "css_extract"
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
led_start: Optional[int] = Field(None, description="LED range start", ge=0)
led_end: Optional[int] = Field(None, description="LED range end")
color_strip_source_id: str | None = Field(None, description="Color strip source ID")
led_start: int | None = Field(None, description="LED range start", ge=0)
led_end: int | None = Field(None, description="LED range end")
class SystemMetricsValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["system_metrics"] = "system_metrics"
metric: Optional[str] = Field(None, description="System metric")
min_value: Optional[float] = Field(None, description="Min value")
max_value: Optional[float] = Field(None, description="Max value")
max_rate: Optional[float] = Field(None, description="Max rate bytes/sec")
disk_path: Optional[str] = Field(None, description="Disk path")
sensor_label: Optional[str] = Field(None, description="Sensor label")
poll_interval: Optional[float] = Field(None, description="Poll interval", ge=0.1, le=60.0)
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
metric: str | None = Field(None, description="System metric")
min_value: float | None = Field(None, description="Min value")
max_value: float | None = Field(None, description="Max value")
max_rate: float | None = Field(None, description="Max rate bytes/sec")
disk_path: str | None = Field(None, description="Disk path")
sensor_label: str | None = Field(None, description="Sensor label")
poll_interval: float | None = Field(None, description="Poll interval", ge=0.1, le=60.0)
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
class HTTPValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["http"] = "http"
http_endpoint_id: Optional[str] = Field(None, description="HTTP endpoint ID")
json_path: Optional[str] = Field(None, description="Dot-path into the response")
interval_s: Optional[int] = Field(None, description="Polling cadence (seconds)", ge=1)
min_value: Optional[float] = Field(None, description="Raw value mapped to 0.0")
max_value: Optional[float] = Field(None, description="Raw value mapped to 1.0")
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
http_endpoint_id: str | None = Field(None, description="HTTP endpoint ID")
json_path: str | None = Field(None, description="Dot-path into the response")
interval_s: int | None = Field(None, description="Polling cadence (seconds)", ge=1)
min_value: float | None = Field(None, description="Raw value mapped to 0.0")
max_value: float | None = Field(None, description="Raw value mapped to 1.0")
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
ValueSourceUpdate = Annotated[
Union[
Annotated[StaticValueSourceUpdate, Tag("static")],
Annotated[AnimatedValueSourceUpdate, Tag("animated")],
Annotated[AudioValueSourceUpdate, Tag("audio")],
Annotated[AdaptiveTimeValueSourceUpdate, Tag("adaptive_time")],
Annotated[AdaptiveSceneValueSourceUpdate, Tag("adaptive_scene")],
Annotated[DaylightValueSourceUpdate, Tag("daylight")],
Annotated[StaticColorValueSourceUpdate, Tag("static_color")],
Annotated[AnimatedColorValueSourceUpdate, Tag("animated_color")],
Annotated[AdaptiveTimeColorValueSourceUpdate, Tag("adaptive_time_color")],
Annotated[HAEntityValueSourceUpdate, Tag("ha_entity")],
Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")],
Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")],
Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")],
Annotated[HTTPValueSourceUpdate, Tag("http")],
],
Annotated[StaticValueSourceUpdate, Tag("static")]
| Annotated[AnimatedValueSourceUpdate, Tag("animated")]
| Annotated[AudioValueSourceUpdate, Tag("audio")]
| Annotated[AdaptiveTimeValueSourceUpdate, Tag("adaptive_time")]
| Annotated[AdaptiveSceneValueSourceUpdate, Tag("adaptive_scene")]
| Annotated[DaylightValueSourceUpdate, Tag("daylight")]
| Annotated[StaticColorValueSourceUpdate, Tag("static_color")]
| Annotated[AnimatedColorValueSourceUpdate, Tag("animated_color")]
| Annotated[AdaptiveTimeColorValueSourceUpdate, Tag("adaptive_time_color")]
| Annotated[HAEntityValueSourceUpdate, Tag("ha_entity")]
| Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")]
| Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")]
| Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")]
| Annotated[HTTPValueSourceUpdate, Tag("http")],
Discriminator("source_type"),
]
@@ -1,7 +1,7 @@
"""Weather source schemas (CRUD)."""
from datetime import datetime
from typing import Dict, List, Literal, Optional
from typing import Dict, List, Literal
from pydantic import BaseModel, Field
@@ -13,7 +13,7 @@ class WeatherSourceCreate(BaseModel):
provider: Literal["open_meteo"] = Field(
default="open_meteo", description="Weather data provider"
)
provider_config: Optional[Dict] = Field(None, description="Provider-specific configuration")
provider_config: Dict | None = Field(None, description="Provider-specific configuration")
latitude: float = Field(
default=50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0
)
@@ -23,14 +23,14 @@ class WeatherSourceCreate(BaseModel):
update_interval: int = Field(
default=600, description="API poll interval in seconds (60-3600)", ge=60, le=3600
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the channel accent.",
@@ -40,26 +40,26 @@ class WeatherSourceCreate(BaseModel):
class WeatherSourceUpdate(BaseModel):
"""Request to update a weather source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
provider: Optional[Literal["open_meteo"]] = Field(None, description="Weather data provider")
provider_config: Optional[Dict] = Field(None, description="Provider-specific configuration")
latitude: Optional[float] = Field(
name: str | None = Field(None, description="Source name", min_length=1, max_length=100)
provider: Literal["open_meteo"] | None = Field(None, description="Weather data provider")
provider_config: Dict | None = Field(None, description="Provider-specific configuration")
latitude: float | None = Field(
None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0
)
longitude: Optional[float] = Field(
longitude: float | None = Field(
None, description="Geographic longitude (-180 to 180)", ge=-180.0, le=180.0
)
update_interval: Optional[int] = Field(
update_interval: int | None = Field(
None, description="API poll interval in seconds (60-3600)", ge=60, le=3600
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
icon: Optional[str] = Field(
description: str | None = Field(None, description="Optional description", max_length=500)
tags: List[str] | None = None
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
@@ -78,14 +78,14 @@ class WeatherSourceResponse(BaseModel):
latitude: float = Field(description="Geographic latitude")
longitude: float = Field(description="Geographic longitude")
update_interval: int = Field(description="API poll interval in seconds")
description: Optional[str] = Field(None, description="Description")
description: str | None = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: Optional[str] = Field(
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library.",
)
icon_color: Optional[str] = Field(
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
@@ -11,7 +11,7 @@ capture stream (WASAPI, sounddevice, etc.).
import threading
import time
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Tuple
from ledgrab.core.audio.analysis import (
AudioAnalysis,
@@ -49,7 +49,7 @@ class ManagedAudioStream:
engine_type: str,
device_index: int,
is_loopback: bool,
engine_config: Optional[Dict[str, Any]] = None,
engine_config: Dict[str, Any] | None = None,
):
self._engine_type = engine_type
self._device_index = device_index
@@ -57,9 +57,9 @@ class ManagedAudioStream:
self._engine_config = engine_config or {}
self._running = False
self._thread: Optional[threading.Thread] = None
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
self._latest: Optional[AudioAnalysis] = None
self._latest: AudioAnalysis | None = None
self._last_timing: dict = {}
def start(self) -> None:
@@ -90,7 +90,7 @@ class ManagedAudioStream:
f"device={self._device_index}"
)
def get_latest_analysis(self) -> Optional[AudioAnalysis]:
def get_latest_analysis(self) -> AudioAnalysis | None:
with self._lock:
return self._latest
@@ -98,7 +98,7 @@ class ManagedAudioStream:
return dict(self._last_timing)
def _capture_loop(self) -> None:
stream: Optional[AudioCaptureStreamBase] = None
stream: AudioCaptureStreamBase | None = None
try:
stream = AudioEngineRegistry.create_stream(
self._engine_type,
@@ -178,8 +178,8 @@ class AudioCaptureManager:
self,
device_index: int,
is_loopback: bool,
engine_type: Optional[str] = None,
engine_config: Optional[Dict[str, Any]] = None,
engine_type: str | None = None,
engine_config: Dict[str, Any] | None = None,
) -> ManagedAudioStream:
"""Get or create a ManagedAudioStream for the given device.
@@ -220,7 +220,7 @@ class AudioCaptureManager:
self,
device_index: int,
is_loopback: bool,
engine_type: Optional[str] = None,
engine_type: str | None = None,
) -> None:
"""Release a reference to a ManagedAudioStream."""
if engine_type is None:
+2 -2
View File
@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -83,7 +83,7 @@ class AudioCaptureStreamBase(ABC):
pass
@abstractmethod
def read_chunk(self) -> Optional[np.ndarray]:
def read_chunk(self) -> np.ndarray | None:
"""Read one chunk of raw audio data.
Returns:
+2 -2
View File
@@ -1,7 +1,7 @@
"""Demo audio engine — virtual audio devices with synthetic audio data."""
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -62,7 +62,7 @@ class DemoAudioCaptureStream(AudioCaptureStreamBase):
self._initialized = False
logger.info(f"Demo audio stream cleaned up (device={self.device_index})")
def read_chunk(self) -> Optional[np.ndarray]:
def read_chunk(self) -> np.ndarray | None:
if not self._initialized:
return None
+2 -2
View File
@@ -1,6 +1,6 @@
"""Engine registry and factory for audio capture engines."""
from typing import Any, Dict, List, Optional, Type
from typing import Any, Dict, List, Type
from ledgrab.core.audio.base import AudioCaptureEngine, AudioCaptureStreamBase
from ledgrab.config import is_demo_mode
@@ -82,7 +82,7 @@ class AudioEngineRegistry:
return available
@classmethod
def get_best_available_engine(cls) -> Optional[str]:
def get_best_available_engine(cls) -> str | None:
"""Get the highest-priority available engine type.
Returns:
@@ -8,7 +8,6 @@ from ledgrab.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from ledgrab.core.audio.filters.registry import AudioFilterRegistry
from ledgrab.core.audio.band_filter import apply_band_filter, compute_band_mask
# Preset frequency ranges
_PRESETS = {
"bass": (20.0, 250.0),
@@ -4,7 +4,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
from ledgrab.core.audio.analysis import AudioAnalysis
@@ -20,8 +20,8 @@ class AudioFilterOptionDef:
min_value: Any
max_value: Any
step: Any
choices: Optional[List[Dict[str, str]]] = None # for "select": [{value, label}]
max_length: Optional[int] = None # for "string" type
choices: List[Dict[str, str]] | None = None # for "select": [{value, label}]
max_length: int | None = None # for "string" type
def to_dict(self) -> dict:
d = {
@@ -1,6 +1,6 @@
"""Sounddevice audio capture engine (cross-platform, via PortAudio)."""
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -79,7 +79,7 @@ class SounddeviceCaptureStream(AudioCaptureStreamBase):
self._sd_stream = None
self._initialized = False
def read_chunk(self) -> Optional[np.ndarray]:
def read_chunk(self) -> np.ndarray | None:
if self._sd_stream is None:
return None
try:
@@ -1,6 +1,6 @@
"""WASAPI audio capture engine (Windows only, via PyAudioWPatch)."""
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -98,7 +98,7 @@ class WasapiCaptureStream(AudioCaptureStreamBase):
self._pa = None
self._initialized = False
def read_chunk(self) -> Optional[np.ndarray]:
def read_chunk(self) -> np.ndarray | None:
if self._stream is None:
return None
try:
@@ -109,7 +109,7 @@ class WasapiCaptureStream(AudioCaptureStreamBase):
return None
@staticmethod
def _find_loopback_device(pa, output_device_index: int) -> Optional[dict]:
def _find_loopback_device(pa, output_device_index: int) -> dict | None:
"""Find the PyAudioWPatch loopback device for a given output device."""
try:
first_loopback = None
@@ -4,7 +4,7 @@ import asyncio
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable, Dict, Optional, Set
from typing import Callable, Dict, Set
from ledgrab.core.automations.platform_detector import PlatformDetector
from ledgrab.storage.automation import (
@@ -38,11 +38,11 @@ class _RuleEvalContext:
"""
running_procs: Set[str]
topmost_proc: Optional[str]
topmost_proc: str | None
topmost_fullscreen: bool
fullscreen_procs: Set[str]
idle_seconds: Optional[float]
display_state: Optional[str]
idle_seconds: float | None
display_state: str | None
def _apply_operator(operator: str, extracted, expected: str) -> bool:
@@ -101,7 +101,7 @@ class AutomationEngine:
self._target_store = target_store
self._device_store = device_store
self._ha_manager = ha_manager
self._task: Optional[asyncio.Task] = None
self._task: asyncio.Task | None = None
self._eval_lock = asyncio.Lock()
# Runtime state (not persisted)
@@ -420,11 +420,11 @@ class AutomationEngine:
self,
automation: Automation,
running_procs: Set[str],
topmost_proc: Optional[str],
topmost_proc: str | None,
topmost_fullscreen: bool,
fullscreen_procs: Set[str],
idle_seconds: Optional[float],
display_state: Optional[str],
idle_seconds: float | None,
display_state: str | None,
) -> bool:
results = [
self._evaluate_rule(
@@ -453,11 +453,11 @@ class AutomationEngine:
self,
rule: Rule,
running_procs: Set[str],
topmost_proc: Optional[str],
topmost_proc: str | None,
topmost_fullscreen: bool,
fullscreen_procs: Set[str],
idle_seconds: Optional[float],
display_state: Optional[str],
idle_seconds: float | None,
display_state: str | None,
) -> bool:
ctx = _RuleEvalContext(
running_procs=running_procs,
@@ -531,14 +531,14 @@ class AutomationEngine:
return current >= start or current <= end
@staticmethod
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: Optional[float]) -> bool:
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
if idle_seconds is None:
return False
is_idle = idle_seconds >= (rule.idle_minutes * 60)
return is_idle if rule.when_idle else not is_idle
@staticmethod
def _evaluate_display_state(rule: DisplayStateRule, display_state: Optional[str]) -> bool:
def _evaluate_display_state(rule: DisplayStateRule, display_state: str | None) -> bool:
if display_state is None:
return False
return display_state == rule.state
@@ -612,7 +612,7 @@ class AutomationEngine:
self,
rule: ApplicationRule,
running_procs: Set[str],
topmost_proc: Optional[str],
topmost_proc: str | None,
topmost_fullscreen: bool,
fullscreen_procs: Set[str],
) -> bool:
@@ -9,7 +9,7 @@ import ctypes
import os
import sys
import threading
from typing import Optional, Set
from typing import Set
from ledgrab.utils import get_logger
@@ -84,6 +84,21 @@ class PlatformDetector:
]
user32.DefWindowProcW.restype = ctypes.c_ssize_t
# Pin the MSG pointer type so byref(msg) matches the prototype
# (Python 3.13 ctypes rejects mismatched POINTER(MSG) caches).
LPMSG = ctypes.POINTER(ctypes.wintypes.MSG)
user32.GetMessageW.argtypes = [
LPMSG,
ctypes.wintypes.HWND,
ctypes.c_uint,
ctypes.c_uint,
]
user32.GetMessageW.restype = ctypes.c_int
user32.TranslateMessage.argtypes = [LPMSG]
user32.TranslateMessage.restype = ctypes.wintypes.BOOL
user32.DispatchMessageW.argtypes = [LPMSG]
user32.DispatchMessageW.restype = ctypes.c_ssize_t
def wnd_proc(hwnd, msg, wparam, lparam):
if msg == WM_POWERBROADCAST and wparam == PBT_POWERSETTINGCHANGE:
try:
@@ -164,7 +179,7 @@ class PlatformDetector:
except Exception as e:
logger.error(f"Display power listener failed: {e}")
def _get_display_power_state_sync(self) -> Optional[str]:
def _get_display_power_state_sync(self) -> str | None:
"""Get display power state: 'on' or 'off'. Returns None if unavailable."""
if not _IS_WINDOWS:
return None
@@ -172,7 +187,7 @@ class PlatformDetector:
# ---- System idle detection ----
def _get_idle_seconds_sync(self) -> Optional[float]:
def _get_idle_seconds_sync(self) -> float | None:
"""Get system idle time in seconds (keyboard/mouse inactivity).
Returns None if detection is unavailable.
+91 -13
View File
@@ -1,10 +1,12 @@
"""Auto-backup engine — periodic SQLite snapshot backups."""
"""Auto-backup engine — periodic SQLite + assets snapshot backups."""
import asyncio
import os
import tempfile
import zipfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import List, Optional
from typing import Iterable, List
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger
@@ -20,21 +22,37 @@ DEFAULT_SETTINGS = {
# Skip the immediate-on-start backup if a recent backup exists within this window.
_STARTUP_BACKUP_COOLDOWN = timedelta(minutes=5)
_BACKUP_EXT = ".db"
# Current write format. ``.db`` is still recognised on read so backups taken
# by older versions remain listable, restorable, and prunable.
_BACKUP_EXT = ".zip"
_RECOGNISED_EXTS: tuple[str, ...] = (".zip", ".db")
# Soft warning threshold — large backups indicate an unbounded assets dir or
# bloated DB. We don't refuse to write (user data is theirs), but log loudly
# so the operator can investigate before disk fills up over many intervals.
_BACKUP_SIZE_WARN_BYTES = 500 * 1024 * 1024 # 500 MB
class AutoBackupEngine:
"""Creates periodic SQLite snapshot backups of the database."""
"""Creates periodic backups of the database and asset files.
Each backup is a ZIP archive containing ``ledgrab.db`` plus every file
from ``assets_dir`` under ``assets/`` matching the format produced by
the manual ``GET /api/v1/system/backup`` download. The restore endpoint
accepts either ``.zip`` or ``.db`` interchangeably.
"""
def __init__(
self,
backup_dir: Path,
db: Database,
assets_dir: Path | None = None,
):
self._backup_dir = Path(backup_dir)
self._db = db
self._task: Optional[asyncio.Task] = None
self._last_backup_time: Optional[datetime] = None
self._assets_dir = Path(assets_dir) if assets_dir else None
self._task: asyncio.Task | None = None
self._last_backup_time: datetime | None = None
self._settings = self._load_settings()
self._backup_dir.mkdir(parents=True, exist_ok=True)
@@ -82,9 +100,14 @@ class AutoBackupEngine:
self._task.cancel()
self._task = None
def _iter_backup_files(self) -> Iterable[Path]:
"""Yield every backup file (both legacy ``.db`` and current ``.zip``)."""
for ext in _RECOGNISED_EXTS:
yield from self._backup_dir.glob(f"*{ext}")
def _most_recent_backup_age(self) -> timedelta | None:
"""Return the age of the newest backup file, or None if no backups exist."""
files = list(self._backup_dir.glob(f"*{_BACKUP_EXT}"))
files = list(self._iter_backup_files())
if not files:
return None
newest = max(files, key=lambda p: p.stat().st_mtime)
@@ -124,15 +147,72 @@ class AutoBackupEngine:
timestamp = now.strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-backup-{timestamp}{_BACKUP_EXT}"
file_path = self._backup_dir / filename
# Stage the ZIP at <name>.partial then os.replace into place once it's
# fully written. A crash mid-write leaves a .partial file (cleaned up
# on the next backup) but never a half-written backup that would fool
# ``_most_recent_backup_age`` / ``_prune_old_backups`` into trusting
# corrupt data.
partial_path = file_path.with_suffix(file_path.suffix + ".partial")
self._db.backup_to(file_path)
# SQLite backup API → temp .db so we get a consistent snapshot
# without holding the DB lock for the ZIP write.
tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
tmp_path = Path(tmp.name)
tmp.close()
asset_count = 0
try:
self._db.backup_to(tmp_path)
with zipfile.ZipFile(partial_path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.write(tmp_path, "ledgrab.db")
if self._assets_dir and self._assets_dir.is_dir():
for asset_file in self._assets_dir.iterdir():
# Skip symlinks: ``is_file()`` follows them and we
# don't want to silently slurp a symlink target that
# lives outside the assets dir into every backup.
if asset_file.is_symlink():
continue
if asset_file.is_file():
zf.write(asset_file, f"assets/{asset_file.name}")
asset_count += 1
os.replace(partial_path, file_path)
except Exception:
# Roll back the staged partial so it doesn't accumulate; the
# finally block still removes the SQLite temp file. Re-raise so
# the caller (``_backup_loop`` / ``trigger_backup``) sees + logs
# the failure instead of silently emitting a missing backup.
partial_path.unlink(missing_ok=True)
raise
finally:
tmp_path.unlink(missing_ok=True)
# Best-effort sweep of any older orphan .partial files left by a
# crash on a previous run.
for stale in self._backup_dir.glob("*.partial"):
try:
stale.unlink()
except OSError:
pass
size_bytes = file_path.stat().st_size
self._last_backup_time = now
logger.info(f"Backup created: {filename}")
logger.info(
"Backup created: %s (%d asset files, %.1f MB)",
filename,
asset_count,
size_bytes / (1024 * 1024),
)
if size_bytes > _BACKUP_SIZE_WARN_BYTES:
logger.warning(
"Backup %s is %.1f MB — exceeds %d MB warning threshold; "
"consider pruning the assets directory or lowering max_backups",
filename,
size_bytes / (1024 * 1024),
_BACKUP_SIZE_WARN_BYTES // (1024 * 1024),
)
def _prune_old_backups(self) -> None:
max_backups = self._settings["max_backups"]
files = sorted(self._backup_dir.glob(f"*{_BACKUP_EXT}"), key=lambda p: p.stat().st_mtime)
files = sorted(self._iter_backup_files(), key=lambda p: p.stat().st_mtime)
excess = len(files) - max_backups
if excess > 0:
for f in files[:excess]:
@@ -179,9 +259,7 @@ class AutoBackupEngine:
def list_backups(self) -> List[dict]:
backups = []
for f in sorted(
self._backup_dir.glob(f"*{_BACKUP_EXT}"), key=lambda p: p.stat().st_mtime, reverse=True
):
for f in sorted(self._iter_backup_files(), key=lambda p: p.stat().st_mtime, reverse=True):
stat = f.stat()
backups.append(
{
@@ -1,13 +1,13 @@
"""Pixel processing utilities for color correction and manipulation."""
from typing import List, Tuple, Union
from typing import List, Tuple
import numpy as np
from ledgrab.utils import get_logger
logger = get_logger(__name__)
ColorList = Union[List[Tuple[int, int, int]], np.ndarray]
ColorList = List[Tuple[int, int, int]] | np.ndarray
def _as_array(colors: ColorList) -> np.ndarray:
@@ -6,7 +6,7 @@ import colorsys
import logging
import sys
import threading
from typing import TYPE_CHECKING, Dict, List, Optional
from typing import TYPE_CHECKING, Dict, List
if TYPE_CHECKING:
import tkinter as tk
@@ -41,8 +41,8 @@ class OverlayWindow:
self.calibration = calibration
self.target_id = target_id
self.target_name = target_name or target_id
self._window: Optional[tk.Toplevel] = None
self._canvas: Optional[tk.Canvas] = None
self._window: tk.Toplevel | None = None
self._canvas: tk.Canvas | None = None
self.running = False
# ----- Lifecycle (must run in Tk thread) -----
@@ -352,8 +352,8 @@ class OverlayManager:
def __init__(self):
self._overlays: Dict[str, OverlayWindow] = {}
self._lock = threading.Lock()
self._tk_root: Optional[tk.Tk] = None
self._tk_thread: Optional[threading.Thread] = None
self._tk_root: tk.Tk | None = None
self._tk_thread: threading.Thread | None = None
self._tk_ready = threading.Event()
self._start_tk_thread()
@@ -386,7 +386,7 @@ class OverlayManager:
if self._tk_root is None:
raise RuntimeError("Tkinter root not available")
done = threading.Event()
exc_box: List[Optional[BaseException]] = [None]
exc_box: List[BaseException | None] = [None]
def wrapper():
try:
@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -70,7 +70,7 @@ class CaptureStream(ABC):
pass
@abstractmethod
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
"""Capture one frame from the bound display.
Returns:
@@ -2,7 +2,7 @@
import sys
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
from ledgrab.core.capture_engines.base import (
@@ -104,7 +104,7 @@ class BetterCamCaptureStream(CaptureStream):
logger.error(f"BetterCam reinit failed (display={self.display_index}): {reinit_err}")
return False
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -13,7 +13,7 @@ import platform
import sys
import threading
import time
from typing import Any, Dict, List, Optional, Set
from typing import Any, Dict, List, Set
# OpenCV's MSMF backend on Windows often fails to open the device
# ("cap.isOpened() == False" right after VideoCapture returns) when
@@ -50,7 +50,7 @@ _RESOLUTION_CHOICES: List[str] = [
]
def _parse_resolution(value: Any) -> Optional[tuple[int, int]]:
def _parse_resolution(value: Any) -> tuple[int, int] | None:
"""Parse a 'WxH' string into (width, height). Returns None for 'auto' or invalid."""
if not isinstance(value, str):
return None
@@ -101,7 +101,7 @@ _BUILDINFO_LABELS: Dict[str, str] = {
"avfoundation": "AVFoundation",
}
_compiled_backends_cache: Optional[Set[str]] = None
_compiled_backends_cache: Set[str] | None = None
def _get_compiled_backends() -> Set[str]:
@@ -169,7 +169,7 @@ def _get_supported_backends() -> List[str]:
return ["auto", *(b for b in candidates if b in compiled)]
def _cv2_backend_id(backend_name: str) -> Optional[int]:
def _cv2_backend_id(backend_name: str) -> int | None:
"""Convert a backend name string to cv2 API preference constant."""
return _CV2_BACKENDS.get(backend_name)
@@ -307,7 +307,7 @@ def _get_camera_friendly_names() -> Dict[int, str]:
return {}
_camera_cache: Optional[List[Dict[str, Any]]] = None
_camera_cache: List[Dict[str, Any]] | None = None
_camera_cache_time: float = 0
_CAMERA_CACHE_TTL = 30.0 # seconds
@@ -428,7 +428,7 @@ class CameraCaptureStream(CaptureStream):
def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config)
self._cap = None
self._cv2_index: Optional[int] = None
self._cv2_index: int | None = None
def initialize(self) -> None:
if self._initialized:
@@ -531,7 +531,7 @@ class CameraCaptureStream(CaptureStream):
f"(camera={camera['name']}, cv2_idx={cv2_index}, {w}x{h})"
)
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -1,7 +1,7 @@
"""Demo capture engine — virtual displays with animated test patterns."""
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -65,7 +65,7 @@ class DemoCaptureStream(CaptureStream):
self._initialized = False
logger.info(f"Demo capture stream cleaned up (display={self.display_index})")
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -2,7 +2,7 @@
import sys
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
from ledgrab.core.capture_engines.base import (
@@ -102,7 +102,7 @@ class DXcamCaptureStream(CaptureStream):
logger.error(f"DXcam reinit failed (display={self.display_index}): {reinit_err}")
return False
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -1,6 +1,6 @@
"""Engine registry and factory for screen capture engines."""
from typing import Any, Dict, List, Optional, Type
from typing import Any, Dict, List, Type
from ledgrab.core.capture_engines.base import CaptureEngine, CaptureStream
from ledgrab.config import is_demo_mode
@@ -83,7 +83,7 @@ class EngineRegistry:
return available
@classmethod
def get_best_available_engine(cls) -> Optional[str]:
def get_best_available_engine(cls) -> str | None:
"""Get the highest-priority available engine type.
Returns:
@@ -29,7 +29,7 @@ logger = get_logger(__name__)
# ---------------------------------------------------------------------------
_frame_queue: queue.Queue["ScreenCapture"] = queue.Queue(maxsize=2)
_display_info: Optional[DisplayInfo] = None
_display_info: DisplayInfo | None = None
_active = False
_frames_received = 0
_frames_consumed = 0
@@ -141,7 +141,7 @@ class MediaProjectionCaptureStream(CaptureStream):
self._initialized = True
logger.info("MediaProjection capture stream initialized")
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
# Prefer fresh frames from the queue; fall back to the last
@@ -1,6 +1,6 @@
"""MSS-based screen capture engine (cross-platform)."""
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import mss
import numpy as np
@@ -41,7 +41,7 @@ class MSSCaptureStream(CaptureStream):
self._rgb_idx: int = 0
self._rgb_shape: tuple = (0, 0)
# Cheap hash of the previous .raw bytes, for change detection.
self._prev_hash: Optional[int] = None
self._prev_hash: int | None = None
def initialize(self) -> None:
try:
@@ -59,7 +59,7 @@ class MSSCaptureStream(CaptureStream):
self._prev_hash = None
logger.info(f"MSS capture stream cleaned up (display={self.display_index})")
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -35,7 +35,7 @@ logger = get_logger(__name__)
# ---------------------------------------------------------------------------
_frame_queue: queue.Queue["ScreenCapture"] = queue.Queue(maxsize=2)
_display_info: Optional[DisplayInfo] = None
_display_info: DisplayInfo | None = None
_active = False
_frames_received = 0
# screenrecord emits a full bitstream every frame (keyframes aside), so
@@ -123,7 +123,7 @@ class RootScreenrecordCaptureStream(CaptureStream):
self._initialized = True
logger.info("Root screenrecord capture stream initialized")
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
try:
@@ -14,7 +14,7 @@ video stream. No APK installation, no root.
"""
import threading
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -96,12 +96,12 @@ class ScrcpyClientCaptureStream(CaptureStream):
def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config)
self._client: Optional["scrcpy.Client"] = None
self._latest_frame: Optional[ScreenCapture] = None
self._client: "scrcpy.Client" | None = None
self._latest_frame: ScreenCapture | None = None
self._frame_lock = threading.Lock()
self._frame_event = threading.Event()
self._client_thread: Optional[threading.Thread] = None
self._device_serial: Optional[str] = None
self._client_thread: threading.Thread | None = None
self._device_serial: str | None = None
def initialize(self) -> None:
if self._initialized:
@@ -189,7 +189,7 @@ class ScrcpyClientCaptureStream(CaptureStream):
)
self._frame_event.set()
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -18,7 +18,7 @@ import shutil
import subprocess
import threading
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -78,7 +78,7 @@ def _find_adb() -> str:
return "adb" # last resort — will fail with FileNotFoundError
_adb_path: Optional[str] = None
_adb_path: str | None = None
def _get_adb() -> str:
@@ -158,7 +158,7 @@ def _list_adb_devices() -> List[Dict[str, Any]]:
return devices
def _screencap_once(adb: str, serial: str) -> Optional[np.ndarray]:
def _screencap_once(adb: str, serial: str) -> np.ndarray | None:
"""Capture a single PNG screenshot and return it as an RGB NumPy array."""
try:
result = subprocess.run(
@@ -190,12 +190,12 @@ class ScrcpyCaptureStream(CaptureStream):
def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config)
self._capture_thread: Optional[threading.Thread] = None
self._latest_frame: Optional[ScreenCapture] = None
self._capture_thread: threading.Thread | None = None
self._latest_frame: ScreenCapture | None = None
self._frame_lock = threading.Lock()
self._frame_event = threading.Event()
self._running = False
self._device_serial: Optional[str] = None
self._device_serial: str | None = None
def initialize(self) -> None:
if self._initialized:
@@ -281,7 +281,7 @@ class ScrcpyCaptureStream(CaptureStream):
if poll_interval > 0:
time.sleep(poll_interval)
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -3,7 +3,7 @@
import gc
import sys
import threading
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import numpy as np
@@ -199,7 +199,7 @@ class WGCCaptureStream(CaptureStream):
gc.collect(0)
logger.info(f"WGC capture stream cleaned up (display={self.display_index})")
def capture_frame(self) -> Optional[ScreenCapture]:
def capture_frame(self) -> ScreenCapture | None:
if not self._initialized:
self.initialize()
@@ -3,7 +3,7 @@
import asyncio
import concurrent.futures
from datetime import datetime, timezone
from typing import Optional, Tuple
from typing import Tuple
import numpy as np
@@ -41,7 +41,7 @@ def _build_adalight_header(led_count: int) -> bytes:
class AdalightClient(LEDClient):
"""LED client for Arduino Adalight serial devices."""
def __init__(self, url: str, led_count: int = 0, baud_rate: Optional[int] = None, **kwargs):
def __init__(self, url: str, led_count: int = 0, baud_rate: int | None = None, **kwargs):
"""Initialize Adalight client.
Args:
@@ -62,11 +62,11 @@ class AdalightClient(LEDClient):
# Pre-allocated wire buffer (header + RGB payload). Resized on the
# first frame and reused thereafter so the hot path performs no
# allocations — only a single memcpy of the pixel bytes.
self._frame_buf: Optional[bytearray] = None
self._frame_buf: bytearray | None = None
self._frame_buf_n: int = 0
# Scratch uint8 array used to coerce non-uint8 / non-contiguous input
# without allocating a fresh array per frame.
self._u8_scratch: Optional[np.ndarray] = None
self._u8_scratch: np.ndarray | None = None
self._u8_scratch_n: int = 0
# Dedicated single-worker executor for serial writes. Using
# ``loop.run_in_executor`` against this avoids the per-call
@@ -74,7 +74,7 @@ class AdalightClient(LEDClient):
# that ``asyncio.to_thread`` incurs (~510 µs per call), and
# guarantees FIFO ordering of writes from this client even when
# other tasks are using the default executor.
self._tx_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
self._tx_executor: concurrent.futures.ThreadPoolExecutor | None = None
async def connect(self) -> bool:
"""Open serial port and wait for Arduino reset."""
@@ -245,7 +245,7 @@ class AdalightClient(LEDClient):
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
prev_health: DeviceHealth | None = None,
) -> DeviceHealth:
"""Check if the serial port exists without opening it.
@@ -10,7 +10,7 @@ so ``BLEClient`` can treat both backends identically.
from __future__ import annotations
import asyncio
from typing import List, Optional
from typing import List
from ledgrab.core.devices.ble_transport import DiscoveredBLEDevice
from ledgrab.utils import get_logger
@@ -49,7 +49,7 @@ async def android_ble_scan(timeout: float = 4.0) -> List[DiscoveredBLEDevice]:
continue
address, name, rssi_str = parts
try:
rssi: Optional[int] = int(rssi_str)
rssi: int | None = int(rssi_str)
except ValueError:
rssi = None
devices.append(DiscoveredBLEDevice(address=address, name=name or address, rssi=rssi))
@@ -80,7 +80,7 @@ class AndroidBLETransport:
self._address = address
self._write_char_uuid = write_char_uuid
self._write_with_response = write_with_response
self._handle: Optional[int] = None
self._handle: int | None = None
self._lock = asyncio.Lock()
@property
@@ -120,7 +120,7 @@ class AndroidBLETransport:
except Exception as exc:
logger.warning("Android BLE disconnect of %s raised: %s", self._address, exc)
async def write(self, data: bytes, char_uuid: Optional[str] = None) -> None:
async def write(self, data: bytes, char_uuid: str | None = None) -> None:
"""Write bytes to a characteristic on the connected peripheral.
Serialised through an internal lock BLE stacks do not tolerate

Some files were not shown because too many files have changed in this diff Show More