Compare commits
45 Commits
v0.6.1
...
fd46c51dba
| Author | SHA1 | Date | |
|---|---|---|---|
| fd46c51dba | |||
| ddae5719cf | |||
| 898912f8b1 | |||
| 45d12b2811 | |||
| 826e680f37 | |||
| 737fd72b73 | |||
| 3fe66d80cb | |||
| f03cb303c3 | |||
| 9ff83bd6ca | |||
| d6cc80074d | |||
| 06273ba2bc | |||
| 628c6b2f0d | |||
| 2f15fbb752 | |||
| c1aa2ebec5 | |||
| 3b8f00e3f9 | |||
| 05f73eedf9 | |||
| 9f3f346543 | |||
| 98fb61d932 | |||
| 5fec8db901 | |||
| 97dae2cd62 | |||
| 29bdacf69a | |||
| 563cbac88c | |||
| e24f9d33cc | |||
| e4bf58da19 | |||
| f1b0f0eab2 | |||
| 17684afba1 | |||
| 0e3ae78de7 | |||
| 7736bc6f58 | |||
| 390d2b472c | |||
| cc87fba0dd | |||
| 426484adf8 | |||
| 2f31680823 | |||
| 31c6c3abb2 | |||
| 887131d4af | |||
| 8f9d490063 | |||
| ede627b4ac | |||
| 4b65005823 | |||
| 8f1140abad | |||
| 337984c618 | |||
| 530316c2c3 | |||
| 6e4c1b6642 | |||
| ee4fa81376 | |||
| f184ef0afb | |||
| ad84b60ae4 | |||
| cdf7d94652 |
@@ -97,3 +97,6 @@ Thumbs.db
|
||||
.DS_Store
|
||||
# Added by code-review-graph
|
||||
.code-review-graph/
|
||||
|
||||
# vex semantic-search embedding cache (auto-downloaded on first --semantic run)
|
||||
.fastembed_cache/
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# vex configuration — https://github.com/tenatarika/vex
|
||||
#
|
||||
# Place this file in your project root as .vex.toml
|
||||
|
||||
# Glob patterns to exclude from indexing (gitignore syntax, on top of .gitignore)
|
||||
# exclude = [
|
||||
# "vendor/**",
|
||||
# "node_modules/**",
|
||||
# "*.generated.go",
|
||||
# "dist/**",
|
||||
# ]
|
||||
|
||||
# Default output format: "text", "json", or "compact"
|
||||
# format = "text"
|
||||
|
||||
# Enable semantic embeddings by default (slower indexing, enables meaning-based search)
|
||||
semantic = true
|
||||
|
||||
# Automatically run `vex update` before search if the index is stale
|
||||
auto_update = true
|
||||
|
||||
# Embedder used for semantic indexing. Known IDs: minilm-l6-v2 (default).
|
||||
# Changing the embedder requires a full reindex.
|
||||
# embedder = "minilm-l6-v2"
|
||||
@@ -0,0 +1,347 @@
|
||||
# LedGrab Architecture Audit — Remaining Items
|
||||
|
||||
Roadmap for the architecture-audit refactor sprint that started 2026-05-22.
|
||||
This file lists every audit finding that is **not yet addressed**; the ones
|
||||
already landed in commits `563cbac..2f15fbb` are summarised below for
|
||||
context.
|
||||
|
||||
## Already done (10 commits)
|
||||
|
||||
| Commit | Findings addressed |
|
||||
|---|---|
|
||||
| `563cbac` | C2, C11, C1 (parallel-change only), C3, C4, C6, C7-streams |
|
||||
| `29bdacf` | C5 (HA/Z2M swap helper; full ABC deferred) |
|
||||
| `97dae2c` | H1 |
|
||||
| `5fec8db` | M4 |
|
||||
| `98fb61d` | H2 |
|
||||
| `9f3f346` | M5 |
|
||||
| `05f73ee` | H6 (bindable extraction only) |
|
||||
| `3b8f00e` + `c1aa2eb` | C7 store-side |
|
||||
| `2f15fbb` | H3 |
|
||||
|
||||
All commits have ≥1 code-review subagent pass with HIGH findings fixed
|
||||
before commit. Tests pass on each commit; ruff clean; tsc + bundle build
|
||||
clean for the frontend commit.
|
||||
|
||||
The two CRITICAL **data-safety** items (C2 silent CSS fallback, C11
|
||||
string-replace JSON migration) are fixed. The two CRITICAL
|
||||
**parallel-change** problems for color-strip + value-source dispatch are
|
||||
fixed. The two HIGH dispatch problems (H1 effects, H2 rules) are fixed.
|
||||
|
||||
---
|
||||
|
||||
## Remaining backend items
|
||||
|
||||
### HIGH
|
||||
|
||||
#### H4 — `Device.__init__` 40+ params mixing per-type fields
|
||||
|
||||
**File:** `server/src/ledgrab/storage/device_store.py:46-150`
|
||||
|
||||
The `Device` dataclass constructor accepts ~40 parameters that mix common
|
||||
fields with DMX-only / DDP-only / Hue-only / Yeelight-only / Wiz-only /
|
||||
LIFX-only / Govee-only / Nanoleaf-only / SPI-only / Chroma-only /
|
||||
GameSense-only fields. Setting `hue_username` on a WLED device is
|
||||
silently ignored.
|
||||
|
||||
**Approach:** introduce per-device-type config dataclasses
|
||||
(`DmxConfig`, `HueConfig`, `DdpConfig`, …) and make `Device.config` a
|
||||
discriminated union. Per-type validation moves to the config classes.
|
||||
Wire migration: every existing device row needs to be re-parsed; use the
|
||||
versioned `MigrationRunner` introduced in Phase 1.2.
|
||||
|
||||
**Risk:** medium-high. Touches:
|
||||
- `storage/device_store.py` — Device dataclass, `from_dict`, `to_dict`,
|
||||
`create_device`, `update_device`
|
||||
- `api/schemas/devices.py` — Pydantic schemas
|
||||
- `api/routes/devices.py` — request validation
|
||||
- `core/devices/*` — every provider reads device fields
|
||||
- A new migration to translate flat fields → nested `config`
|
||||
|
||||
**Estimated scope:** ~1500 LOC diff, 1-2 dedicated sessions.
|
||||
|
||||
#### H5 — `WledTargetProcessor` god class (32 methods, 5 responsibilities)
|
||||
|
||||
**File:** `server/src/ledgrab/core/processing/wled_target_processor.py` (1238 LOC)
|
||||
|
||||
Conflates:
|
||||
1. Device connectivity (probe, liveness, reconnect)
|
||||
2. FPS negotiation (adaptive_fps, keepalive_interval, state_check_interval)
|
||||
3. LED resampling (`_fit_to_device` — 60 lines of numpy)
|
||||
4. Preview WebSocket fanout (`_preview_clients`, `_broadcast_led_preview`)
|
||||
5. Metrics emission (`get_state`, `get_metrics`)
|
||||
|
||||
**Approach:** extract `WledDeviceConnector`, `WledPixelSender`,
|
||||
`TargetFitProcessor`, `TargetPreviewBroadcaster`, `TargetMetricsCollector`.
|
||||
`WledTargetProcessor` becomes an orchestrator that composes them.
|
||||
|
||||
**Risk:** HIGHEST in the audit. This class drives physical LED hardware
|
||||
in production. A regression caught at runtime (in the user's living
|
||||
room) is the expensive failure mode. Needs manual verification with at
|
||||
least one real WLED device after the refactor.
|
||||
|
||||
**Coupled with:** C5 (HA/Z2M shared the same shape; should extract a
|
||||
common `BaseTargetProcessor` ABC at the same time so all three
|
||||
processors share lifecycle / preview / metrics code).
|
||||
|
||||
**Estimated scope:** ~2000 LOC diff, 2-3 dedicated sessions, with manual
|
||||
device testing after each.
|
||||
|
||||
#### H7 — `device-discovery.ts` 1745 LOC
|
||||
|
||||
Frontend mirror of H4. The `onDeviceTypeChanged` handler has a giant
|
||||
switch with 15+ device kinds and 15+ `_showXxxFields` / `_buildXxxItems`
|
||||
helpers. Adding a device type requires editing 5 separate frontend hooks.
|
||||
|
||||
**Approach:** mirror the H4 backend redesign — once the storage layer
|
||||
has per-type config objects, the frontend can have a per-type field-set
|
||||
registry. Best done **after** H4 lands so the schemas drive the
|
||||
registry.
|
||||
|
||||
**Estimated scope:** 1-2 sessions; coupled to H4.
|
||||
|
||||
#### H8 — `automations.ts` 1410 LOC
|
||||
|
||||
Frontend mirror of H2 (rule polymorphism). Already addressed on the
|
||||
backend in `98fb61d`; the frontend dispatch on `RuleType` is still
|
||||
hand-rolled.
|
||||
|
||||
**Approach:** introduce a rule-type registry on the frontend matching
|
||||
the backend's `_RULE_HANDLERS` shape.
|
||||
|
||||
**Estimated scope:** half a session.
|
||||
|
||||
### MEDIUM
|
||||
|
||||
#### M1 — `ProcessorManager.add_target` shotgun (11 args, WLED-leak)
|
||||
|
||||
**File:** `server/src/ledgrab/core/processing/processor_manager.py:396`
|
||||
|
||||
Method is named generically (`add_target`) but accepts `protocol="ddp"`
|
||||
and `keepalive_interval` — WLED-only fields. HA and Z2M have sibling
|
||||
methods with their own bespoke params.
|
||||
|
||||
**Approach:** extract a `TargetFactory` (per-kind builders, similar to
|
||||
`value_source_factories.py` from Phase 7). Couple with H5/C5 work.
|
||||
|
||||
#### M2 — `TargetContext` god-bag
|
||||
|
||||
**File:** `server/src/ledgrab/core/processing/processor_manager.py`
|
||||
|
||||
`@dataclass TargetContext` exposes ~8 attributes (device_store,
|
||||
color_strip_stream_manager, value_stream_manager, metrics_history,
|
||||
mqtt_manager, ha_manager, …). Processors silently depend on whichever
|
||||
fields they read. Tests have to construct a huge mock context.
|
||||
|
||||
**Approach:** make per-processor explicit dependency injection. Couple
|
||||
with H5 work.
|
||||
|
||||
#### M3 — Validation duplicated across layers
|
||||
|
||||
Field-level constraints (composite nesting depth, name uniqueness, span
|
||||
ranges) are enforced in route + schema + store. Adding a new constraint
|
||||
means editing 3 places.
|
||||
|
||||
**Approach:** move all validation to the model/schema layer (Pydantic
|
||||
validators + dataclass `__post_init__`). Routes trust the schema; store
|
||||
trusts the model.
|
||||
|
||||
**Risk:** moderate — cross-cutting; needs careful review of which layer
|
||||
currently owns which constraint.
|
||||
|
||||
#### M6 — `ws_stream.py` mixed concerns (699 LOC)
|
||||
|
||||
**File:** `server/src/ledgrab/api/routes/color_strip_sources/ws_stream.py`
|
||||
|
||||
The worst part (stream-creation dispatch) was fixed in Phase 2.1 — it
|
||||
now calls `color_strip_kinds.build_stream(source, deps)`. The remaining
|
||||
699 lines mix config parsing + WebSocket lifecycle + frame loop. Could
|
||||
extract the frame loop into a separate `PreviewFrameLoop` class.
|
||||
|
||||
**Estimated scope:** half a session. Low impact since the parallel-change
|
||||
problem is already fixed.
|
||||
|
||||
#### M7 — No shared frontend API client
|
||||
|
||||
**File:** every `static/js/features/*.ts`
|
||||
|
||||
`fetchWithAuth(...)` + bespoke error-unwrapping is copy-pasted in every
|
||||
feature's save / load function. ~25 files.
|
||||
|
||||
**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.
|
||||
|
||||
#### M8 — Global `_cached*` `let` vars
|
||||
|
||||
Mutable module-level state mutated from multiple feature modules. No
|
||||
subscription model — features manually `invalidate()` after CRUD.
|
||||
|
||||
**Approach:** introduce a reactive cache (EventEmitter pattern or a tiny
|
||||
store like Nano Stores). Couple with M7 (the API client can drive cache
|
||||
invalidation on write).
|
||||
|
||||
#### M9 — `dashboard.ts` 1421 LOC
|
||||
|
||||
Frontend god-module orchestrating + rendering device / target / CSS
|
||||
cards. Couple with C8/C9/C10 frontend split work.
|
||||
|
||||
#### M10 — Duplicate frontend modal classes
|
||||
|
||||
`ValueSourceModal`, `StreamEditorModal`, `TargetEditorModal`,
|
||||
`AddDeviceModal`, etc. each reimplement pristine-check / undo / focus
|
||||
management.
|
||||
|
||||
**Approach:** introduce a `FormModal<T>` base class.
|
||||
|
||||
#### M11 — Hardcoded `_getSectionForSource` / `_getTabForSource`
|
||||
|
||||
Routing tables duplicated across multiple feature files (streams.ts,
|
||||
value-sources.ts). Adding a new stream type requires hunting strings.
|
||||
|
||||
**Approach:** single routing registry keyed by source_type.
|
||||
|
||||
#### M12 — Late imports masking cycles
|
||||
|
||||
Partially addressed by the kind registries (Phase 2.1, 2.2). Some
|
||||
late-imports still exist in `value_stream.py`, `audio_stream.py`, the
|
||||
target processors. Resolving them requires restructuring module layout
|
||||
to break the circular dependencies.
|
||||
|
||||
**Estimated scope:** small follow-up after H5.
|
||||
|
||||
### LOW
|
||||
|
||||
#### L1 — `(src as any).field` casts in `value-sources.ts`
|
||||
|
||||
Discriminated unions aren't narrowed properly. Couple with C8 frontend
|
||||
split.
|
||||
|
||||
#### L2 — Mutable state without locks
|
||||
|
||||
`_preview_clients`, `_last_preview_data`, `_color_stream`,
|
||||
`_css_stream` are mutated from multiple async tasks without explicit
|
||||
locks. Production has not exhibited issues but the contract is fragile.
|
||||
|
||||
**Approach:** add explicit `asyncio.Lock` per processor. Couple with H5.
|
||||
|
||||
#### L3 — `Calibration.validate()` raises instead of returning result
|
||||
|
||||
**File:** `server/src/ledgrab/core/capture/calibration.py:164`
|
||||
|
||||
All 4 call sites currently rely on the raise; converting to
|
||||
`ValidationResult` would force every caller to check a return value
|
||||
without adding safety. **Recommendation:** skip — current design is
|
||||
appropriate.
|
||||
|
||||
#### L4 — `_SOURCE_TYPE_MAP` is module-private
|
||||
|
||||
No public `GET /api/v1/source-types` discovery endpoint. Frontend
|
||||
hardcodes the list of source types in `types.ts`.
|
||||
|
||||
**Approach:** add a discovery route + matching frontend fetch. Couple
|
||||
with H6 frontend split (since `types.ts` is involved).
|
||||
|
||||
#### L5 — `AudioValueStream` implicit state machine
|
||||
|
||||
**File:** `server/src/ledgrab/core/processing/value_stream.py:169-383`
|
||||
|
||||
`get_value()` can be called before `start()`; transitions are implicit.
|
||||
**Approach:** explicit State pattern. Low value (production callers
|
||||
always start before reading).
|
||||
|
||||
---
|
||||
|
||||
## Remaining frontend items (all)
|
||||
|
||||
### CRITICAL
|
||||
|
||||
- **C8** — `value-sources.ts` 1972 LOC (4 god-functions, type-dispatch ladders)
|
||||
- **C9** — `graph-editor.ts` 2707 LOC (layout + interaction + state + WS sync + …)
|
||||
- **C10** — `streams.ts` 2341 LOC (picture / audio / template kitchen-sink)
|
||||
|
||||
### Other frontend (severity in main list above)
|
||||
|
||||
- **H6 rest** — split remaining ~1100 LOC of `types.ts` into per-entity files
|
||||
- **H7** — `device-discovery.ts` 1745 LOC (couple with H4)
|
||||
- **H8** — `automations.ts` 1410 LOC (mirror H2)
|
||||
- **M7** — shared API client
|
||||
- **M8** — reactive cache
|
||||
- **M9** — `dashboard.ts` 1421 LOC
|
||||
- **M10** — `FormModal<T>` base
|
||||
- **M11** — routing registry
|
||||
- **L1** — narrowing the discriminated unions
|
||||
|
||||
The frontend remainder is **multi-day work** even when broken up by
|
||||
finding. Recommended approach: a dedicated frontend sprint with the
|
||||
typescript-reviewer agent + manual UI testing for each god-module
|
||||
split. Order:
|
||||
|
||||
1. Finish `types.ts` split (H6) — pure organisation, low risk, unblocks
|
||||
the rest
|
||||
2. Introduce API client (M7) — every feature file gains a cleaner shape
|
||||
3. Split `value-sources.ts` (C8) — uses the API client + per-type
|
||||
registry pattern
|
||||
4. Split `streams.ts` (C10)
|
||||
5. Split `graph-editor.ts` (C9) — needs the most care; the file owns
|
||||
the entire visual editor
|
||||
6. Polish: `dashboard.ts` (M9), `device-discovery.ts` (H7),
|
||||
`automations.ts` (H8), `FormModal` (M10), routing registry (M11),
|
||||
reactive cache (M8), narrowing (L1)
|
||||
|
||||
---
|
||||
|
||||
## Recommended ordering for future sessions
|
||||
|
||||
### Session A — Frontend sprint (multi-day)
|
||||
|
||||
Address H6-rest, C8, C9, C10, H7, H8, M7-M11, L1. See order above.
|
||||
Critical to have typescript-reviewer feedback + manual UI testing after
|
||||
each split.
|
||||
|
||||
### Session B — Device redesign (1-2 sessions)
|
||||
|
||||
Address H4 alone. Touches device storage + provider classes; needs a
|
||||
data migration. Once H4 lands, H7 frontend mirror can follow.
|
||||
|
||||
### Session C — BaseTargetProcessor ABC (2-3 sessions)
|
||||
|
||||
Address C5 (full) + H5 + M1 + M2 + L2 together. Highest risk in the
|
||||
audit because it drives physical LED hardware. Each step needs manual
|
||||
verification with a real device.
|
||||
|
||||
### Session D — Polish (half a session)
|
||||
|
||||
Address M3, M6 (remainder), M12 (remainder), L3 (decision: skip), L4,
|
||||
L5.
|
||||
|
||||
---
|
||||
|
||||
## Pattern reference for new contributors
|
||||
|
||||
Three registry-pattern templates that already exist in the codebase and
|
||||
should be the model for the remaining dispatch ladders:
|
||||
|
||||
1. **Class-level handler dict + import-time coverage assertion**
|
||||
- `core/processing/effect_stream.py::_RENDERERS`
|
||||
(`@_effect_renderer` decorator + `@_collect_effect_renderers`
|
||||
class decorator)
|
||||
- `core/automations/automation_engine.py::AutomationEngine._RULE_HANDLERS`
|
||||
(module-level binding after class definition)
|
||||
- `api/routes/output_targets.py::_TARGET_RESPONSE_BUILDERS`
|
||||
(response-shape dispatch keyed by storage class)
|
||||
|
||||
2. **Per-type free functions + dependency-bag dataclass**
|
||||
- `core/processing/color_strip_kinds.py` (`StreamDeps` + `STREAM_BUILDERS`)
|
||||
- `core/processing/value_kinds.py` (`ValueStreamDeps` + `STREAM_BUILDERS`)
|
||||
- `storage/value_source_factories.py` (`CREATE_BUILDERS` + `UPDATE_APPLIERS`)
|
||||
|
||||
3. **Versioned migration runner**
|
||||
- `storage/data_migrations.py` (`MigrationRunner` + `DataMigration` ABC)
|
||||
- Used for any storage rename / field-shape change in the future.
|
||||
- Audit-table contract: atomic transaction covers
|
||||
applied-check + apply + record, so partial-failure cannot leave
|
||||
data rewritten but unrecorded.
|
||||
|
||||
Adding a new feature that touches dispatch should reach for one of
|
||||
these three patterns before writing a fresh if/elif chain.
|
||||
@@ -55,10 +55,6 @@ The Android app (`android/app/build.gradle.kts`) installs the server package wit
|
||||
| [Gitea Python CI/CD Guide](https://git.dolgolyov-family.by/alexei.dolgolyov/claude-code-facts/src/branch/main/gitea-python-ci-cd.md) | Reusable CI/CD patterns: Gitea Actions, cross-build, NSIS, Docker |
|
||||
| [server/CLAUDE.md](server/CLAUDE.md) | Backend architecture, API patterns, common tasks |
|
||||
|
||||
## Task Tracking via TODO.md
|
||||
|
||||
Use `TODO.md` in the project root as the primary task tracker. **Do NOT use the TodoWrite tool** — all progress tracking goes through `TODO.md`.
|
||||
|
||||
## Documentation Lookup
|
||||
|
||||
**Use context7 MCP tools for library/framework documentation lookups** (FastAPI, OpenCV, Pydantic, yt-dlp, etc.) instead of relying on potentially outdated training data.
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
# Production Review — Remaining Items
|
||||
|
||||
Output of the multi-agent production review (security / Python / TypeScript /
|
||||
performance / architecture / code-quality). Each entry below is something
|
||||
the original audit flagged and the autonomous hardening pass deliberately
|
||||
did **not** address — either because it needs design input, profiling
|
||||
validation, or a multi-day refactor that should land in its own session.
|
||||
|
||||
The hardening pass landed everything else: see git log between `master` and
|
||||
the head of the review branch for the applied changes (URL-scheme +
|
||||
malicious-input rejection, IconSelect XSS escape, MiniSelect for forbidden
|
||||
plain `<select>`s, WebSocket Origin allow-list, /docs auth-gate, security
|
||||
headers middleware, streaming upload size caps, fire-and-forget task
|
||||
tracking + drain resilience in MQTT runtime, discovery_watcher task
|
||||
tracking, asyncio.gather return_exceptions, secret_box encryption for MQTT
|
||||
/ Hue / Govee credentials with auto-migration, SSRF-validated update
|
||||
redirects, single source of truth for IP classification in
|
||||
`utils/net_classify.py`, allowlist + parity test for inbound WS events,
|
||||
typed `Window` globals, and more).
|
||||
|
||||
---
|
||||
|
||||
## Architecture refactors (multi-day — own session)
|
||||
|
||||
- [ ] **Split `core/processing/value_stream.py`** (1856 LOC, 14 stream classes)
|
||||
into a `value_streams/` package. Each value-stream type gets its own
|
||||
file ≤300 LOC; `manager.py` holds `ValueStreamManager`.
|
||||
- [ ] **Split `storage/color_strip_source.py`** (1841 LOC, 18 source kinds)
|
||||
into a `color_strip_sources/` package mirroring `value_streams/`.
|
||||
- [ ] **Frontend file splits** — `graph-editor.ts` (2707), `streams.ts`
|
||||
(2335), `value-sources.ts` (1889), `types.ts` (1062). Highest-churn
|
||||
modules; mixed UI / state / network responsibilities.
|
||||
- [ ] **Layering reversal**: introduce a neutral `domain/` package and move
|
||||
shared DTOs (`FilterInstance`, `CalibrationConfig`, etc.) into it so
|
||||
`storage/` no longer imports `core/`. Eliminates 7+ layering
|
||||
violations and the lazy-import hacks used to break the resulting
|
||||
circulars.
|
||||
- [ ] **`main.py` boot refactor** — extract import-time side effects into
|
||||
`bootstrap.py` + `create_app()` factory. `lifespan()` becomes the
|
||||
single place that wires stores and managers.
|
||||
- [ ] **DI consolidation** — replace `api/dependencies.py` getter sprawl
|
||||
(30+ `get_*()` functions reading a process-global `_deps` dict) with
|
||||
a single typed `get_container()` dependency. Makes test-overrides
|
||||
trivial; ban direct getter calls in handler bodies.
|
||||
- [ ] **Exception hierarchy** — define `ledgrab/errors.py` (`LedGrabError`,
|
||||
`NotFoundError`, `ValidationError`, `RemoteUnavailableError`,
|
||||
`SSRFBlockedError`). Move HTTP translation into a FastAPI exception
|
||||
handler. Stop raising `HTTPException` from `utils/safe_source.py`.
|
||||
- [ ] **Lazy-import audit** — 289 in-function `from ledgrab.*` imports.
|
||||
Specifically `core/processing/daylight_settings.py` imports
|
||||
`api.dependencies` (core → api inversion). Pass the database in via
|
||||
the constructor instead of service-locator lookup.
|
||||
|
||||
## Performance (profile before applying)
|
||||
|
||||
- [ ] **`composite_stream.py` blend modes** — pre-allocate scratch buffers
|
||||
in `_blend_override / overlay / hard_light / soft_light / difference
|
||||
/ exclusion`. Each currently allocates per frame (`mul`, `scr`,
|
||||
`blended`, `np.where(...)`). At 100 LEDs × 30 fps × N layers this
|
||||
adds up.
|
||||
- [ ] **`mapped_stream` / `composite_stream` zone resize** — replace the
|
||||
per-channel `np.interp` calls with a cached `floor/ceil/frac` LUT
|
||||
(same trick as `wled_target_processor._fit_to_device`) or a single
|
||||
`cv2.resize` call on the (N,3) array. `np.interp` allocates a new
|
||||
`float64` array per channel per frame even on cache-hit.
|
||||
- [ ] **`processed_stream._processing_loop`** — add ping-pong output
|
||||
buffers and pass them as `out=` to filter `process_strip()` calls.
|
||||
Today every filter that returns a fresh allocation costs us a copy
|
||||
per frame. Also: the loop uses `time.sleep` instead of an
|
||||
event-driven wait on the input stream — input updates faster than
|
||||
30 fps see up to `frame_time` of latency.
|
||||
- [ ] **`mqtt_client.py` `send_pixels`** — add a binary publish path (or
|
||||
at minimum cache the outer dict skeleton). Today every frame
|
||||
`pixels.tolist()` + `json.dumps` for ~300 LEDs × 30 fps × N devices.
|
||||
- [ ] **Frontend `static/js/features/color-strips/test.ts`** — cache
|
||||
`ImageData` per canvas (`canvas._imageData`); only re-create on
|
||||
dimension change; use a `Uint32Array` view to copy pixels in one
|
||||
loop instead of the per-pixel JS loop. Border-overlay rebuild on
|
||||
every frame should also be debounced to dimension changes only.
|
||||
- [ ] **`ws_stream.py` composite branch** — pre-allocate a `bytearray`
|
||||
sized to the largest frame and write into slices instead of
|
||||
`b"".join(tobytes()) per layer` every iteration. Same anti-pattern
|
||||
in `wled_target_processor._broadcast_led_preview`.
|
||||
- [ ] **Preview broadcast slow-client guard** — `asyncio.gather` over
|
||||
preview clients waits for the slowest. Move to `asyncio.wait` with a
|
||||
timeout and drop slow clients, or fire-and-forget with a
|
||||
`ws.application_state` filter.
|
||||
|
||||
## Security (deferred — non-trivial or design-sensitive)
|
||||
|
||||
- [ ] **Content-Security-Policy header** — would need careful tuning
|
||||
because the UI uses inline event handlers / Jinja templates.
|
||||
Mis-set CSP would break the app silently. Defer until templates can
|
||||
move to event-delegated handlers, then add a strict policy.
|
||||
- [ ] **`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.
|
||||
- [ ] **Hue bridge cert pinning** — `httpx.AsyncClient(verify=False)` for
|
||||
Hue bridge (self-signed cert by design). Should record the
|
||||
certificate fingerprint at pairing time and pin it on subsequent
|
||||
requests; otherwise an on-path attacker can MITM the bridge.
|
||||
|
||||
## Mechanical / code-quality (low risk, high line-count)
|
||||
|
||||
- [ ] **i18n parity** — **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.
|
||||
- [ ] **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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
- [ ] **`asyncio.gather` over uncapped client lists** in preview broadcasts
|
||||
— slow clients block the loop. Already noted under Performance
|
||||
above; pre-existing.
|
||||
@@ -1,5 +1,299 @@
|
||||
# LedGrab TODO
|
||||
|
||||
## HTTP polling automation trigger
|
||||
|
||||
Goal: a new automation trigger that periodically polls an HTTP endpoint
|
||||
and activates a scene when the response matches a condition. Split into
|
||||
three single-responsibility entities so the endpoint can be reused
|
||||
beyond automations (e.g. as a value-source driving brightness/color):
|
||||
|
||||
- `HTTPEndpoint` (storage/http_endpoint.py) — connection definition:
|
||||
URL + auth + headers + timeout. NO polling cadence; NO extraction.
|
||||
- `HTTPValueSource` (storage/value_source.py, source_type='http') —
|
||||
references an endpoint + owns json_path + interval + min/max + EMA
|
||||
smoothing. Backed by `HTTPValueStream` (core/processing/value_stream.py)
|
||||
which lives under the existing `ValueStreamManager` (ref-counted,
|
||||
one poll task per unique value source).
|
||||
- `HTTPPollRule` (storage/automation.py) — thin: `{value_source_id,
|
||||
operator, value}`. Reads `stream.get_raw_value()` from the value
|
||||
source and compares with `_apply_operator`.
|
||||
|
||||
Pivoted from a 2-entity shape mid-build (was: HTTPSource+rule with
|
||||
interval+json_path mushed). The 3-entity shape mirrors HA's pattern
|
||||
(HomeAssistantSource → HAEntityValueSource → rule).
|
||||
|
||||
### Phase 1 — endpoint + value source + thin rule (backend) ✅
|
||||
|
||||
- [x] `storage/http_endpoint.py` — `HTTPEndpoint` dataclass with
|
||||
secret_box auth_token encryption + `__post_init__` plaintext
|
||||
invariant. NO `default_interval_s` (moved to value source).
|
||||
- [x] `storage/http_endpoint_store.py` — `HTTPEndpointStore` with
|
||||
`_migrate_plaintext_tokens()`. ID prefix `htep_`.
|
||||
- [x] `storage/database.py` — `"http_endpoints"` in `_ENTITY_TABLES`
|
||||
(replaces the old `"http_sources"`).
|
||||
- [x] `storage/value_source.py` — added `HTTPValueSource` alongside
|
||||
`HAEntityValueSource` (endpoint_id, json_path, interval_s,
|
||||
min/max, smoothing). Registered in `_VALUE_SOURCE_MAP`.
|
||||
- [x] `storage/value_source_store.py` — CRUD branch for `source_type =
|
||||
"http"` + new kwargs on create/update.
|
||||
- [x] `core/processing/value_stream.py` — `HTTPValueStream` with poll
|
||||
task + `get_value()` (normalized 0-1) + `get_raw_value()` (raw
|
||||
extracted value). Dispatched in `ValueStreamManager._create_stream`.
|
||||
Manager now takes `http_endpoint_store` so the stream can resolve
|
||||
endpoints at fetch time.
|
||||
|
||||
### Phase 2 — rule + engine wiring ✅
|
||||
|
||||
- [x] `storage/automation.py` — `HTTPPollRule` is now thin: just
|
||||
`{value_source_id, operator, value}` (no http_source_id, no
|
||||
json_path on the rule). Legacy keys silently dropped on load.
|
||||
- [x] `core/automations/automation_engine.py` — drops the standalone
|
||||
http_poll_manager; takes `value_stream_manager`. Engine
|
||||
`_sync_value_stream_refs` acquires/releases value streams for
|
||||
every enabled HTTPPollRule, mirroring the HA/MQTT sync pattern.
|
||||
`_evaluate_http_poll` reads `stream.get_raw_value()` and applies
|
||||
the operator. `_apply_operator` kept at module top.
|
||||
- [x] `api/schemas/automations.py` — RuleSchema fields are now
|
||||
`value_source_id + operator + value` (dropped http_source_id +
|
||||
json_path).
|
||||
- [x] `api/routes/automations.py` — `http_poll` factory updated.
|
||||
|
||||
### Phase 3 — CRUD endpoints + wiring ✅
|
||||
|
||||
- [x] `api/schemas/http_endpoints.py` — Create/Update/Response/List/Test
|
||||
(no interval field; that's on the value source).
|
||||
- [x] `api/routes/http_endpoints.py` — full CRUD + `/test` +
|
||||
plaintext-http-token warning.
|
||||
- [x] `api/schemas/value_sources.py` — `HTTPValueSource{Create,Update,Response}`
|
||||
added to the discriminated unions.
|
||||
- [x] `api/routes/value_sources.py` — `_RESPONSE_MAP` entry for
|
||||
`HTTPValueSource`.
|
||||
- [x] `api/__init__.py` — `http_endpoints_router` registered.
|
||||
- [x] `api/dependencies.py` — `get_http_endpoint_store` (dropped the
|
||||
http_poll_manager getter).
|
||||
- [x] `main.py` — instantiate `HTTPEndpointStore`, pass it through
|
||||
`ProcessorDependencies`, wire `value_stream_manager` +
|
||||
`value_source_store` into `AutomationEngine`.
|
||||
- [x] `core/processing/processor_manager.py` — `ProcessorDependencies`
|
||||
gains `http_endpoint_store`; threaded into `ValueStreamManager`.
|
||||
|
||||
### Phase 4 — tests ✅
|
||||
|
||||
- [x] `tests/storage/test_http_endpoint_store.py` — 14 tests (CRUD +
|
||||
auth_token encryption + headers + case-insensitive Authorization).
|
||||
- [x] `tests/core/test_automation_engine.py` — `TestApplyOperator` +
|
||||
`TestHTTPPollRuleEvaluation` (new shape: mock ValueStreamManager
|
||||
with `_streams` dict) + `TestSyncValueStreamRefs` (acquire /
|
||||
release / disabled-ignored) + `TestHTTPValueStreamExtraction`
|
||||
(`_extract_simple_path` now lives in value_stream.py).
|
||||
- [x] `tests/api/routes/test_http_endpoints_routes.py` — CRUD shape, no
|
||||
auth_token leak in responses, schema-layer method allowlist,
|
||||
CRLF / invalid header rejection, `/test` endpoint, LAN policy.
|
||||
- [x] Removed: `tests/core/test_http_poll_manager.py` (manager deleted —
|
||||
polling now lives inside `HTTPValueStream`).
|
||||
- [x] Full suite: 1426 passed, ruff clean.
|
||||
|
||||
### Phase 5 — frontend ✅
|
||||
|
||||
- [x] `static/js/features/http-endpoints.ts` (new, ~540 LOC) — endpoint
|
||||
CRUD, modal subclass with dirty-check, headers row editor, test
|
||||
result rendering, card builder, event delegation. Mirrors
|
||||
`home-assistant-sources.ts`.
|
||||
- [x] `templates/modals/http-endpoint-editor.html` (new) — sectioned
|
||||
rack-panel modal (Identity / Request / Headers / Notes) with
|
||||
IconSelect method picker, password-toggle on auth token, inline
|
||||
Test button + result block.
|
||||
- [x] `static/js/features/value-sources.ts` — added `http` branch with
|
||||
EntitySelect over `httpEndpointsCache`, edit-data/defaults,
|
||||
`onValueSourceTypeChange` section toggle, save-payload assembly
|
||||
+ required-field validation.
|
||||
- [x] `templates/modals/value-source-editor.html` — new
|
||||
`#value-source-http-section` with endpoint picker + json_path +
|
||||
interval + min/max + smoothing.
|
||||
- [x] `static/js/features/automations.ts` — `http_poll` rule type with
|
||||
operator IconSelect + value-source EntitySelect; hides Value
|
||||
field when operator is `exists`.
|
||||
- [x] `static/js/features/integrations.ts` — `csHTTPEndpoints` section,
|
||||
tree/tab entry, render + reconcile + delegation paths.
|
||||
- [x] `static/js/types.ts` — `HTTPEndpoint`, `HTTPMethod`,
|
||||
`HTTPEndpointListResponse`, `HTTPTestRequest/Response`,
|
||||
`HTTPValueSource`, `HTTPPollOperator`; extended `RuleType` +
|
||||
`AutomationRule`.
|
||||
- [x] `static/js/core/state.ts` — `httpEndpointsCache` (`/http/endpoints`).
|
||||
- [x] `static/js/core/icons.ts` — `http: P.globe` in
|
||||
`_valueSourceTypeIcons`.
|
||||
- [x] `templates/index.html` — includes
|
||||
`modals/http-endpoint-editor.html`.
|
||||
- [x] Locales: 77 new keys per file in `en.json` / `ru.json` /
|
||||
`zh.json` (parity confirmed).
|
||||
- [x] Verification: `npx tsc --noEmit` clean; `npm run build` clean
|
||||
(app.bundle.css 366.6kb, app.bundle.js 2.7mb).
|
||||
|
||||
### Follow-ups (out of scope for initial PR)
|
||||
|
||||
- [ ] **Global concurrency cap / minimum interval.** Each
|
||||
`HTTPValueStream` runs its own task at `interval_s` (min 1s); no
|
||||
project-wide cap. Reviewer flagged: pick a min (e.g. 5s) + max
|
||||
active runtimes (e.g. 32) + shared `httpx.AsyncClient` with
|
||||
`limits=httpx.Limits(max_connections=N)`.
|
||||
- [ ] **DNS-rebinding hardening.** `safe_request_bounded` validates
|
||||
the URL hostname's resolved IPs once; httpx independently
|
||||
re-resolves. The window is short but not zero. True fix: pin
|
||||
to the validated IP + set Host header (and SNI for HTTPS). This
|
||||
affects every outbound caller (`safe_fetch`, weather, image
|
||||
sources) — handle as a project-wide hardening, not local to
|
||||
this feature.
|
||||
- [ ] **`delete_http_endpoint` orphan refs.** When an admin deletes an
|
||||
endpoint referenced by N value sources, the value-stream task
|
||||
keeps polling until its source is also deleted. Same shape as
|
||||
the MQTT defect — fix both together (refuse-with-409 when in
|
||||
use, or cascade value-source deletion).
|
||||
- [ ] **Per-endpoint `connected` / last-poll status on the response**
|
||||
(frontend agent flagged). `HTTPEndpointResponse` has no live
|
||||
status, unlike HA/MQTT sources. Card LEDs default to "on".
|
||||
Could aggregate `last_status_code` / `last_error` from all
|
||||
`HTTPValueStream` instances referencing the endpoint and surface
|
||||
on `GET /http/endpoints/{id}`.
|
||||
- [x] **Per-endpoint live `/test` after save** — added `POST
|
||||
/http/endpoints/{id}/test` (runs stored config server-side so the
|
||||
auth token never round-trips) and wired a flask-icon test action
|
||||
on the endpoint card (toasts the result). Custom-headers section
|
||||
and inline test-result UI in the editor modal also restyled to
|
||||
match the `.group-child-row` and result-card vocabulary.
|
||||
- [ ] **Dedicated icon for HTTP value source / endpoint** (frontend
|
||||
agent flagged). Both use `P.globe` — visually fine in practice
|
||||
but adding a `cable`/`webhook` glyph in `icon-paths.ts` would
|
||||
improve differentiation.
|
||||
|
||||
## Multi-broker MQTT refactor
|
||||
|
||||
Goal: drop the global `MQTTService` / `MQTTConfig`. Every MQTT consumer
|
||||
references an `MQTTSource.id`; `MQTTManager` is the only entry point.
|
||||
`MQTTManager` + `MQTTRuntime` already exist — the job is to migrate every
|
||||
caller off the legacy path, then delete it.
|
||||
|
||||
### Phase 1 — `mqtt_source_id` on Z2M target
|
||||
|
||||
- [x] Field on `Z2MLightOutputTarget` storage dataclass (+ to/from_dict)
|
||||
- [x] Field on Z2M create/update/response schemas
|
||||
- [x] Validate referenced `MQTTSource` exists at create/update
|
||||
- [x] Thread through `output_target_store.create_z2m_light_target` + update
|
||||
- [x] Thread through `ProcessorManager.add_z2m_light_target`
|
||||
- [x] Thread through `Z2MLightTargetProcessor` constructor
|
||||
|
||||
### Phase 2 — Z2M processor uses `MQTTManager`
|
||||
|
||||
- [x] Replace `_mqtt_service` with `_mqtt_runtime` acquired from manager
|
||||
- [x] `start()` acquire / `stop()` release
|
||||
- [x] `_publish_payload` → `self._mqtt_runtime.publish(...)`
|
||||
- [x] `turn_off_lights` borrow-pattern via manager (mirror HA-light)
|
||||
- [x] Add `mqtt_manager` to `ProcessorDependencies` / `TargetContext`
|
||||
|
||||
### Phase 3 — Z2M editor UI
|
||||
|
||||
- [x] Add MQTT broker `EntitySelect` in Routing
|
||||
- [x] Reuse `mqttSourcesCache`
|
||||
- [x] Wire `mqtt_source_id` into edit-load + save payload + validation
|
||||
|
||||
### Phase 4 — DIY MQTT device (`MQTTLEDClient`)
|
||||
|
||||
- [x] `mqtt_source_id` field on `Device` storage
|
||||
- [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)*
|
||||
|
||||
### Phase 5 — `AutomationEngine`
|
||||
|
||||
- [x] Drop `mqtt_service` ctor parameter
|
||||
- [x] Drop legacy fallback in `_evaluate_mqtt` (rule must reference a source)
|
||||
|
||||
### 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)
|
||||
|
||||
### Phase 7 — Startup migration
|
||||
|
||||
- [x] Seed a "Default Broker" `MQTTSource` if legacy YAML / env had a
|
||||
broker configured and the store is empty (`core.mqtt.legacy_migration`)
|
||||
- [x] Deprecation warning logged on migration; YAML/env no longer read after
|
||||
|
||||
### Phase 8 — Remove legacy
|
||||
|
||||
- [x] Delete `core/mqtt/mqtt_service.py`
|
||||
- [x] Delete `set_mqtt_service` / `get_mqtt_service` (mqtt_client.py)
|
||||
- [x] Remove `MQTTService` from `main.py`
|
||||
- [x] Remove `MQTTConfig` + `resolve_mqtt_password` from `config.py`
|
||||
- [x] Remove `mqtt: MQTTConfig` from `Config` (with `extra="ignore"` so legacy
|
||||
YAML still loads)
|
||||
|
||||
### Phase 9 — Verification
|
||||
|
||||
- [x] `pytest tests/ --no-cov -q` clean (973 passing; removed obsolete
|
||||
`test_default_mqtt_disabled`)
|
||||
- [x] `ruff check src/` clean
|
||||
- [x] `tsc --noEmit` + `npm run build`
|
||||
- [ ] Smoke test: Z2M target on a configured MQTT Source publishes to broker
|
||||
(manual)
|
||||
|
||||
## Refactor: typed output-target factories + auto-registry
|
||||
|
||||
Replaced `target_type` string elif chains in `OutputTargetStore` and
|
||||
`OutputTarget.from_dict` with: (1) `__init_subclass__` registry for
|
||||
deserialization, (2) per-type typed `create_*_target` /
|
||||
`update_*_target` methods called directly from the route layer's
|
||||
`match data:` dispatch. API contract unchanged, no DB migration.
|
||||
|
||||
### Phase 1 — Registry on `OutputTarget`
|
||||
|
||||
- [x] Added `_registry` + `_type_key` ClassVars + `__init_subclass__(*, type_key)`
|
||||
- [x] Rewrote `OutputTarget.from_dict` to dispatch via registry
|
||||
- [x] Declared `type_key="led"` / `"ha_light"` / `"z2m_light"` on the three subclasses
|
||||
|
||||
### Phase 2 — Typed `create_*_target` methods
|
||||
|
||||
- [x] Extracted `_resolve_brightness`, `_resolve_transition`, `_check_unique_name`,
|
||||
`_new_id_and_now`, `_finalize` helpers on the store
|
||||
- [x] Added `create_wled_target` / `create_ha_light_target` / `create_z2m_light_target`
|
||||
with per-type defaults (transition 0.5/0.3, update_rate 2.0/5.0) baked into
|
||||
their signatures
|
||||
|
||||
### Phase 3 — Typed `update_*_target` methods
|
||||
|
||||
- [x] Added `update_wled_target` / `update_ha_light_target` / `update_z2m_light_target`
|
||||
with `_begin_update` / `_commit_update` helpers
|
||||
- [x] Each typed update method validates the target's class before mutating
|
||||
|
||||
### Phase 4 — Route migration
|
||||
|
||||
- [x] `create_target` route uses `match data:` to call typed store methods —
|
||||
no more `getattr(data, "x", default)` pyramid
|
||||
- [x] `update_target` route uses `match data:` and computes `settings_changed` /
|
||||
`css_changed` / `brightness_changed` per-arm from typed fields
|
||||
- [x] Helpers `_build_ha_mappings`, `_build_z2m_mappings`,
|
||||
`_validate_device_exists`, `_resolve_effective_color_vs_id` extracted
|
||||
|
||||
### Phase 5 — Decision: keep both shims
|
||||
|
||||
After grepping for callers, `src/ledgrab/core/scenes/scene_activator.py:90`
|
||||
calls `target_store.update_target(target_id, **changed)` with a dynamically
|
||||
built dict — it legitimately doesn't know the target's type at the call site.
|
||||
The shims are now ~30-line dispatchers that route to typed methods (no more
|
||||
inline construction elif chains), so the original anti-pattern is gone while
|
||||
the generic API remains available for "don't-know-the-type" callers like the
|
||||
scene activator. Tests continue to use the shorthand `create_target("A", "led")`
|
||||
form without churn.
|
||||
|
||||
### Phase 6 — Verify
|
||||
|
||||
- [x] `ruff check` clean on all modified files
|
||||
- [x] `py -3.13 -m pytest tests/ --no-cov -q` — 974 passed (was 974 before)
|
||||
- [ ] Manual smoke test in UI: create/edit/delete each of the three target types
|
||||
|
||||
## Custom card icons — extend to all card types
|
||||
|
||||
Migrate the existing icon-plate work (devices, LED targets, HA-light targets)
|
||||
@@ -131,7 +425,7 @@ Branch: `feat/device-event-notifications`. Default ON.
|
||||
permission row + Test-notification button.
|
||||
- [x] i18n: `settings.notifications.*` and `notifications.*` keys in en/ru/zh.
|
||||
|
||||
### Verification
|
||||
### Verification (notifications)
|
||||
|
||||
- [x] `npx tsc --noEmit` clean, `npm run build` produces 2.5 MB bundle.
|
||||
- [x] `ruff check src/ tests/` clean. 899/899 pytest pass.
|
||||
@@ -530,3 +824,159 @@ Replace flat `DeviceInfo` + `**kwargs` provider contract with a discriminated un
|
||||
- [x] Phases 2+3 — narrow `LEDDeviceProvider.create_client` to typed configs; migrate 3 call sites; delete `DeviceInfo` + `_get_device_info` + `_DEVICE_FIELD_DEFAULTS` (single PR)
|
||||
- [x] Phase 4 — migrate `tests/test_group_device.py` to `GroupConfig`/`ProviderDeps`; remove legacy `GroupLEDClient` init path; 47-test config suite with 100% coverage on `device_config.py`
|
||||
- [ ] Phase 5 (separate PR, optional) — Pydantic v2 discriminated union in `api/schemas/devices.py`; scope frontend POST/PATCH payloads by `device_type`
|
||||
|
||||
## Expand device support (Phase 1: open protocols)
|
||||
|
||||
Branch: `feat/expand-device-support`.
|
||||
|
||||
Goal: maximize the universe of LED controllers LedGrab can drive by adding aggregator + open-protocol providers in roughly-this order. Each driver follows the established `LEDDeviceProvider` + `*Config` + tests pattern.
|
||||
|
||||
### Phase 1.1 — Standalone DDP target ✅ shipped (commit `8f1140a`)
|
||||
|
||||
DDP packet layer (previously WLED-internal) promoted to a first-class device
|
||||
type. Pixelblaze, ESPixelStick, xLights/Falcon endpoints, and generic DDP
|
||||
receivers are now drivable directly without WLED in the path.
|
||||
|
||||
### Phase 1.2 — Yeelight LAN
|
||||
|
||||
Xiaomi/Yeelight bulbs, port 55443 TCP JSON. Direct protocol (no
|
||||
`python-yeelight` dependency — implementation is ~200 lines).
|
||||
|
||||
- [x] `YeelightConfig` dataclass with `yeelight_min_interval_ms` rate limit
|
||||
- [x] `YeelightClient` in `core/devices/yeelight_client.py` — TCP JSON-RPC,
|
||||
averaging single-pixel adapter, client-side rate gate
|
||||
- [x] SSDP-style discovery (Yeelight's variant on `239.255.255.250:1982`)
|
||||
- [x] `YeelightDeviceProvider` with validate/health/discover
|
||||
- [x] Storage + API schemas + route handler wiring
|
||||
- [x] 34 unit tests (URL parsing, RGB packing, averaging, rate limit, SSDP
|
||||
parsing, provider validate/discover, Device.to_config round-trip)
|
||||
- [ ] Frontend: Yeelight in device-type picker + edit form (spawned to a
|
||||
`frontend-design` subagent)
|
||||
- [ ] Locale strings (en/ru/zh)
|
||||
- [ ] Music mode (~60 Hz updates via reverse-TCP) — follow-up, current
|
||||
MVP caps at ~2 Hz via the client-side rate gate
|
||||
|
||||
### Phase 1.3 — WiZ Connected
|
||||
|
||||
Philips' UDP-local budget tier. Port 38899 JSON UDP.
|
||||
|
||||
- [x] `WiZConfig` + `WiZClient` + `WiZDeviceProvider`
|
||||
- [x] UDP broadcast discovery on 255.255.255.255:38899 with the standard
|
||||
`registration` envelope; replies parsed for IP+MAC.
|
||||
- [x] Sync `send_pixels_fast` for the hot loop (UDP is fire-and-forget,
|
||||
no async needed). 50 ms default min interval → ~20 Hz cap.
|
||||
- [x] Health check sends `getPilot` and waits for any reply.
|
||||
- [x] Storage + API schemas + route handler wiring
|
||||
- [x] 36 unit tests
|
||||
- [ ] Frontend: WiZ in device-type picker + edit form
|
||||
- [ ] Locale strings (en/ru/zh)
|
||||
|
||||
### Phase 2 — Unified discovery + pairing UX layer
|
||||
|
||||
After phase 1 the codebase will have 3 fresh examples of "ping the LAN, listen for replies, present a list". Factor that out into a generic discovery scaffold + a "first-run pairing" UX component before adding Tuya/Govee/etc., which each need a one-time pairing dance.
|
||||
|
||||
- [WONTDO] Generic `NetworkDiscoveryService` — the existing
|
||||
`/api/v1/devices/discover` route already runs all providers in parallel
|
||||
via `asyncio.gather(return_exceptions=True)`. Extracting it would not
|
||||
unlock anything; revisit only if discovery cadence/dedup becomes a
|
||||
real complaint.
|
||||
- [WONTDO] Unified scan UI — already exists; one "Scan network" button
|
||||
triggers the cross-provider fan-out.
|
||||
- [x] **Reusable pair-device scaffold** (the actually-needed piece).
|
||||
Backend: `LEDDeviceProvider.pair_device(url)` abstract method with
|
||||
`PairingNotReady` sentinel; `POST /api/v1/devices/pair` endpoint
|
||||
with status-code mapping (200/400/409/422/502); 8 route tests
|
||||
covering every outcome. Frontend: `templates/modals/pair-device.html`
|
||||
five-state modal (idle / pairing / not-ready / success / failed)
|
||||
with a 30-second SVG progress ring; reusable
|
||||
`static/js/features/pairing-flow.ts` exposing
|
||||
`runPairingFlow({deviceType, url}) → Promise<{fields}>` with
|
||||
`PairingCancelled` sentinel; locale strings in en/ru/zh. No driver
|
||||
uses it yet — Nanoleaf will be the first concrete consumer.
|
||||
|
||||
### Phase 3 — Big aggregator unlocks
|
||||
|
||||
- [ ] ESPHome native API (`aioesphomeapi`)
|
||||
- [ ] Tuya Local (`tinytuya`) — biggest single market unlock; needs the pairing UX from Phase 2
|
||||
- [ ] Matter over IP (forward-looking)
|
||||
- [ ] Hyperion JSON downstream
|
||||
|
||||
### Phase 4 — Major consumer brands
|
||||
|
||||
- [x] **LIFX LAN** — UDP binary protocol on port 56700; RGB→HSBK 16-bit
|
||||
conversion; broadcast discovery via GetService/StateService probe;
|
||||
47 unit tests. Single-pixel adapter shape, identical to WiZ
|
||||
structurally. Frontend wired via subagent.
|
||||
- [x] **Govee LAN API** — UDP JSON on port 4003 (control) + 4002
|
||||
(responses) + 4001 (multicast discovery on 239.255.255.250).
|
||||
Single-pixel `colorwc` command with `colorTemInKelvin=0` for RGB
|
||||
mode. **Per-device "LAN Control" toggle required in Govee Home
|
||||
app.** 40 unit tests. Frontend wired via subagent.
|
||||
- [x] **Nanoleaf OpenAPI** — Light Panels / Canvas / Shapes / Lines /
|
||||
Elements via HTTP REST on port 16021. **First concrete user of
|
||||
the pairing-UX scaffold from Phase 2.** mDNS discovery via
|
||||
`_nanoleafapi._tcp`. Single-pixel adapter (averaged strip → HSB
|
||||
`PUT /state`). Auth token encrypted at rest via `_enc`/`_dec`.
|
||||
42 unit tests covering URL parsing, RGB→HSB conversion, pairing
|
||||
handshake (200/403/500/missing-token/transport-error), state
|
||||
mutations, brightness clamping, Device.to_config round-trip
|
||||
including encrypted-token roundtrip.
|
||||
- [ ] Twinkly — multi-pixel + login flow; deferred
|
||||
- [WONTDO] Mi-Light / MiBoxer UDP gateway — the recommended path for
|
||||
modern Mi-Light deployments is `esp8266_milight_hub` firmware → MQTT,
|
||||
which LedGrab already supports through the existing MQTT device target
|
||||
(commit `530316c`). Native V6 driver would be ~400 lines + finicky
|
||||
session protocol + custom 1-byte hue table; the marginal benefit over
|
||||
the MQTT path is small. Revisit if a user complaint surfaces.
|
||||
|
||||
### Phase 5 — Open pixel protocols (cheap completionism)
|
||||
|
||||
- [x] **OPC (Open Pixel Control)** — TCP, port 7890, 4-byte header
|
||||
`[channel][cmd][len_hi][len_lo]` + RGB body. Channel 0 broadcasts.
|
||||
Single-pixel-strip protocol, no discovery, no pairing. 36 unit
|
||||
tests. Fadecandy + xLights + hobbyist receivers reachable.
|
||||
- [ ] TPM2.net
|
||||
|
||||
### Phase 6 — PC gaming RGB completion
|
||||
|
||||
- [ ] Corsair iCUE SDK
|
||||
- [ ] Logitech LIGHTSYNC
|
||||
- [ ] ASUS Aura SDK
|
||||
|
||||
### Phase 7 — Proprietary USB HID ambient kits
|
||||
|
||||
- [ ] Generic HID-ambient framework + VID/PID registry
|
||||
- [ ] First reverse-engineered target (probably Govee Immersion / DreamView)
|
||||
|
||||
### Cleanup + verification
|
||||
|
||||
- [x] **`_average_color` extraction** (commit `cc87fba`). Six identical
|
||||
copies (Yeelight / WiZ / LIFX / Govee / Nanoleaf / BLE) collapsed
|
||||
into `core/devices/pixel_reduce.average_color`. Net -76 lines.
|
||||
Hue is out by design — its Entertainment API addresses up to seven
|
||||
lights individually.
|
||||
- [x] **Pre-merge verification pass.** 1358 pytest tests pass; ruff
|
||||
clean across all device modules and tests; black clean against
|
||||
the pre-commit-pinned 24.10.0; `npx tsc --noEmit` clean; bundle
|
||||
compiles.
|
||||
- [x] **Pre-merge code review (subagent)** — surfaced 2 CRITICAL +
|
||||
4 HIGH + 3 MEDIUM + 3 LOW findings.
|
||||
- [x] **All review findings fixed** (commits `7736bc6` + `0e3ae78`):
|
||||
- CRITICAL #1: missing `url_scheme.py` / `net_classify.py`
|
||||
committed (4 files / 557 lines).
|
||||
- CRITICAL #2: `update_device` no longer re-encrypts secrets in
|
||||
memory via the `to_dict()` round-trip (uses `vars()` directly).
|
||||
- HIGH #3: `nanoleaf_token` / `hue_username` / `hue_client_key`
|
||||
stripped from `DeviceResponse`; replaced with paired-flag
|
||||
booleans. Frontend updated.
|
||||
- HIGH #4: `validate_lan_host()` rejects literal public IPs at
|
||||
each driver's `validate_device` + `pair_device`.
|
||||
- HIGH #5: `_dec()` failures clear the field and log, not crash
|
||||
the row.
|
||||
- HIGH #6: update route now rstrip's URL for all device types.
|
||||
- MEDIUM #7: Govee discovery serialized via `asyncio.Lock`.
|
||||
- MEDIUM #8: Nanoleaf mDNS browser cleanup moved to `finally`.
|
||||
- MEDIUM #9: pair endpoint sanitizes URL userinfo in logs.
|
||||
- LOW: Nanoleaf `.port` property added; pair-then-create E2E test
|
||||
added.
|
||||
- Tests: 1379 pass (+21 regression tests).
|
||||
|
||||
@@ -6,15 +6,18 @@ server:
|
||||
# For LAN access, add your machine's IP, e.g. "http://192.168.1.100:8080"
|
||||
cors_origins:
|
||||
- "http://localhost:8080"
|
||||
- "http://192.168.2.100:8080"
|
||||
|
||||
auth:
|
||||
# API keys — required for any non-loopback (LAN) request.
|
||||
# When empty:
|
||||
# When empty (default):
|
||||
# - loopback (127.0.0.1, ::1, localhost) requests are allowed anonymously
|
||||
# - LAN requests are REJECTED with 401 (security default)
|
||||
# To enable LAN access, add one or more label: "api-key" entries below
|
||||
# and send `Authorization: Bearer <api-key>` with each request.
|
||||
# Generate secure keys: openssl rand -hex 32
|
||||
# To enable LAN access, uncomment the example below and replace the value
|
||||
# with a secret you generated yourself (e.g. `openssl rand -hex 32`).
|
||||
# The previous default `dev: "development-key-change-in-production"` has
|
||||
# been removed — it shipped as a publicly-known token and any deployment
|
||||
# that still uses it grants full LAN access to anyone on the network.
|
||||
api_keys:
|
||||
dev: "development-key-change-in-production"
|
||||
|
||||
|
||||
+86
-28
@@ -288,23 +288,72 @@ $pythonExe = $resolvedPython
|
||||
Write-Info "Starting $Module on port $Port..."
|
||||
if ($SkipBrowser) { $env:LEDGRAB_RESTART = '1' }
|
||||
|
||||
# Redirect the child's stdout/stderr to a log file. Without this, inheriting
|
||||
# the parent shell's handles via Start-Process -WindowStyle Hidden can cause
|
||||
# the child to exit immediately when those handles aren't real console fds
|
||||
# (e.g. when restart.ps1 is driven from WSL/Git-Bash).
|
||||
$logPath = Join-Path $env:TEMP ("ledgrab-{0}-{1}.log" -f $Module, $Port)
|
||||
$errPath = "$logPath.err"
|
||||
# Launch python.exe directly with no parent-handle inheritance. We used to
|
||||
# wrap it in `cmd /c python ... 1>log 2>err` so the parent powershell could
|
||||
# tail crash logs, but that left an empty cmd.exe window hanging around for
|
||||
# the full server lifetime (cmd had to live to hold the redirect handles).
|
||||
# Instead, let python claim its own console window — the user sees the live
|
||||
# server log there, and there's no spurious cmd window.
|
||||
#
|
||||
# Why WMI Win32_Process.Create rather than Start-Process or
|
||||
# [Diagnostics.Process]::Start? Both of those go through CreateProcess with
|
||||
# bInheritHandles=true, which leaks the parent shell's pipe handles into
|
||||
# the new Python process. When the caller is Git-Bash (`restart.ps1 |
|
||||
# tail -10`), the bash pipe then stays open for the full server lifetime,
|
||||
# hanging the bash invocation even after powershell exits. WMI's
|
||||
# Win32_Process.Create uses CreateProcess with bInheritHandles=FALSE.
|
||||
|
||||
$argList = @()
|
||||
$argList += $launchArgs
|
||||
$argList += @('-m', $Module)
|
||||
$startedProc = Start-Process -FilePath $pythonExe `
|
||||
-ArgumentList $argList `
|
||||
-WorkingDirectory $ServerRoot `
|
||||
-WindowStyle Hidden `
|
||||
-RedirectStandardOutput $logPath `
|
||||
-RedirectStandardError $errPath `
|
||||
-PassThru
|
||||
$startedPid = $startedProc.Id
|
||||
|
||||
# Quote each arg defensively in case a future caller adds whitespace.
|
||||
function Quote-CmdArg {
|
||||
param([string]$Arg)
|
||||
if ($Arg -match '[\s"]') {
|
||||
return '"' + ($Arg -replace '"', '\"') + '"'
|
||||
}
|
||||
return $Arg
|
||||
}
|
||||
$quotedArgs = ($argList | ForEach-Object { Quote-CmdArg $_ }) -join ' '
|
||||
$pyQ = Quote-CmdArg $pythonExe
|
||||
|
||||
$cmdLine = $pyQ + ' ' + $quotedArgs
|
||||
|
||||
# Win32_Process.Create starts detached with no parent-handle inheritance.
|
||||
# Returns @{ ProcessId; ReturnValue (0 = success) }.
|
||||
# Title sets the visible console-window title so the user can tell at a
|
||||
# glance which server the window belongs to (useful when running real +
|
||||
# demo side by side on different ports).
|
||||
$startupInfo = New-CimInstance -ClassName Win32_ProcessStartup `
|
||||
-ClientOnly `
|
||||
-Property @{ Title = "LedGrab - $Module (port $Port)" }
|
||||
$wmiResult = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
|
||||
CommandLine = $cmdLine
|
||||
CurrentDirectory = $ServerRoot
|
||||
ProcessStartupInformation = $startupInfo
|
||||
} -ErrorAction SilentlyContinue
|
||||
|
||||
if (-not $wmiResult -or $wmiResult.ReturnValue -ne 0) {
|
||||
Write-Warning "WMI Win32_Process.Create failed (ReturnValue=$($wmiResult.ReturnValue)); falling back to Start-Process"
|
||||
# Fallback path — Start-Process inherits parent handles, so a piped
|
||||
# caller may hang. Acceptable here because this branch only runs when
|
||||
# WMI itself is broken (very rare).
|
||||
$startedProc = Start-Process -FilePath $pythonExe `
|
||||
-ArgumentList $argList `
|
||||
-WorkingDirectory $ServerRoot -PassThru
|
||||
$startedPid = if ($startedProc) { $startedProc.Id } else { 0 }
|
||||
} else {
|
||||
$startedPid = [int]$wmiResult.ProcessId
|
||||
}
|
||||
|
||||
# Confirm the process is actually our server (defensive — WMI sometimes
|
||||
# returns a PID for a transient ancestor on heavily loaded boxes).
|
||||
Start-Sleep -Milliseconds 250
|
||||
if (-not (Get-Process -Id $startedPid -ErrorAction SilentlyContinue)) {
|
||||
$rescanned = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot | Select-Object -First 1
|
||||
if ($rescanned) { $startedPid = $rescanned.ProcessId } else { $startedPid = 0 }
|
||||
}
|
||||
|
||||
# ---- Poll readiness --------------------------------------------------------
|
||||
|
||||
@@ -316,28 +365,37 @@ $deadline = (Get-Date).AddSeconds($StartupTimeoutSec)
|
||||
$ready = $false
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
# Bail early if the process has already exited — something went wrong.
|
||||
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
|
||||
if (-not $proc) { break }
|
||||
if ($startedPid -gt 0) {
|
||||
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
|
||||
if (-not $proc) {
|
||||
$rescanned = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot | Select-Object -First 1
|
||||
if ($rescanned) { $startedPid = $rescanned.ProcessId } else { break }
|
||||
}
|
||||
} else {
|
||||
$rescanned = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot | Select-Object -First 1
|
||||
if ($rescanned) { $startedPid = $rescanned.ProcessId }
|
||||
}
|
||||
if (Test-PortOpen -Port $Port) { $ready = $true; break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
|
||||
if ($ready) {
|
||||
Write-Info "Server ready on port $Port (PID $startedPid)"
|
||||
if ($startedPid -gt 0) {
|
||||
Write-Info "Server ready on port $Port (PID $startedPid)"
|
||||
} else {
|
||||
Write-Info "Server ready on port $Port"
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
|
||||
if (-not $proc) {
|
||||
Write-Warning "Server process $startedPid exited before binding port $Port"
|
||||
} else {
|
||||
Write-Warning "Server PID $startedPid is running but did not bind port $Port within ${StartupTimeoutSec}s"
|
||||
}
|
||||
if (Test-Path $errPath) {
|
||||
$tail = Get-Content $errPath -Tail 20 -ErrorAction SilentlyContinue
|
||||
if ($tail) {
|
||||
Write-Warning "Last stderr lines from $errPath :"
|
||||
$tail | ForEach-Object { Write-Warning " $_" }
|
||||
if ($startedPid -gt 0) {
|
||||
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
|
||||
if (-not $proc) {
|
||||
Write-Warning "Server process $startedPid exited before binding port $Port (check the server console window for the error)"
|
||||
} else {
|
||||
Write-Warning "Server PID $startedPid is running but did not bind port $Port within ${StartupTimeoutSec}s"
|
||||
}
|
||||
} else {
|
||||
Write-Warning "Could not locate server process; port $Port did not bind within ${StartupTimeoutSec}s"
|
||||
}
|
||||
exit 1
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
"""LED Grab - Ambient lighting based on screen content."""
|
||||
|
||||
from importlib.metadata import version, PackageNotFoundError
|
||||
from pathlib import Path
|
||||
|
||||
# Fallback version — kept in sync with pyproject.toml. MUST match the
|
||||
# version declared there on every release. The Windows installer build
|
||||
# (build/build-dist.ps1) also patches this literal to the resolved build
|
||||
# version, so any drift here is corrected for bundled distributions.
|
||||
# Used when the package isn't pip-installed (e.g. embedded via Chaquopy
|
||||
# on Android, where the source is included directly via source sets, or
|
||||
# in the Windows bundle where the installed dist-info is stripped).
|
||||
# Fallback version — patched at build time by build/build-dist.ps1 so the
|
||||
# bundled Windows distribution reports the release version (the installer
|
||||
# strips ledgrab-*.dist-info, so importlib.metadata fails there).
|
||||
# In dev (running from source without `pip install -e .`) and on Android
|
||||
# (Chaquopy embeds the source directly with no dist-info), we additionally
|
||||
# read pyproject.toml so the version is always correct without manual sync.
|
||||
_FALLBACK_VERSION = "0.4.2"
|
||||
|
||||
try:
|
||||
__version__ = version("ledgrab")
|
||||
except PackageNotFoundError:
|
||||
__version__ = _FALLBACK_VERSION
|
||||
|
||||
def _read_pyproject_version() -> str | None:
|
||||
"""Read version from pyproject.toml (server/pyproject.toml relative to this file).
|
||||
|
||||
Returns None if the file is absent (typical for installed/bundled distributions
|
||||
where pyproject.toml isn't shipped) or unreadable.
|
||||
"""
|
||||
try:
|
||||
# __init__.py -> ledgrab/ -> src/ -> server/
|
||||
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
||||
if not pyproject.is_file():
|
||||
return None
|
||||
try:
|
||||
import tomllib # Python 3.11+
|
||||
except ImportError:
|
||||
return None
|
||||
with pyproject.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
v = data.get("project", {}).get("version")
|
||||
return v if isinstance(v, str) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# Prefer pyproject.toml when it sits next to the source (dev checkout). This
|
||||
# avoids stale `pip install -e .` dist-info pinning an older version after a
|
||||
# bump. When pyproject.toml isn't shipped (installed packages, Windows bundle,
|
||||
# Android), fall back to importlib.metadata, then the patched literal.
|
||||
_live = _read_pyproject_version()
|
||||
if _live:
|
||||
__version__ = _live
|
||||
else:
|
||||
try:
|
||||
__version__ = version("ledgrab")
|
||||
except PackageNotFoundError:
|
||||
__version__ = _FALLBACK_VERSION
|
||||
|
||||
__author__ = "Alexei Dolgolyov"
|
||||
__email__ = "dolgolyov.alexei@gmail.com"
|
||||
|
||||
@@ -6,6 +6,7 @@ shows a system-tray icon with **Show UI** / **Exit** actions.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
@@ -42,6 +43,8 @@ from ledgrab.config import get_config # noqa: E402
|
||||
from ledgrab.server_ref import set_server, set_tray # noqa: E402
|
||||
from ledgrab.tray import PYSTRAY_AVAILABLE, TrayManager # noqa: E402
|
||||
from ledgrab.utils import setup_logging, get_logger # noqa: E402
|
||||
from ledgrab.utils.platform import is_windows # noqa: E402
|
||||
from ledgrab.utils.win_shutdown import WindowsShutdownGuard # noqa: E402
|
||||
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
@@ -117,10 +120,22 @@ def main() -> None:
|
||||
server = uvicorn.Server(uv_config)
|
||||
set_server(server)
|
||||
|
||||
# Wire the OS-shutdown safety net. The lifespan in ``ledgrab.main`` signals
|
||||
# ``shutdown_complete`` once it has stopped targets and checkpointed the
|
||||
# DB; the Windows guard waits on that event before letting the OS finish
|
||||
# ending the session. Without this, the entire shutdown lifespan never
|
||||
# runs on PC reboot — devices stay on and the SQLite WAL is lost.
|
||||
guard = _install_os_shutdown_guard(server)
|
||||
|
||||
use_tray = PYSTRAY_AVAILABLE and (sys.platform == "win32" or _force_tray())
|
||||
|
||||
if use_tray:
|
||||
logger.info("Starting with system tray icon")
|
||||
# Install signal handlers BEFORE starting the uvicorn thread so a
|
||||
# SIGINT/SIGBREAK during startup still triggers a clean shutdown.
|
||||
# We do NOT install them on the no-tray path because uvicorn's
|
||||
# ``server.run()`` overwrites SIGINT/SIGTERM with its own handlers.
|
||||
_install_signal_handlers(server)
|
||||
|
||||
# Uvicorn in a background thread
|
||||
server_thread = threading.Thread(
|
||||
@@ -147,12 +162,20 @@ def main() -> None:
|
||||
set_tray(tray)
|
||||
tray.run()
|
||||
|
||||
# Tray exited — wait for server to finish its graceful shutdown
|
||||
server_thread.join(timeout=10)
|
||||
# Tray exited — wait for server to finish its graceful shutdown.
|
||||
# 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)
|
||||
if guard is not None:
|
||||
guard.stop()
|
||||
else:
|
||||
if not PYSTRAY_AVAILABLE:
|
||||
logger.info("System tray not available (install pystray for tray support)")
|
||||
server.run()
|
||||
try:
|
||||
server.run()
|
||||
finally:
|
||||
if guard is not None:
|
||||
guard.stop()
|
||||
|
||||
|
||||
def _request_shutdown(server: uvicorn.Server) -> None:
|
||||
@@ -160,6 +183,57 @@ def _request_shutdown(server: uvicorn.Server) -> None:
|
||||
server.should_exit = True
|
||||
|
||||
|
||||
def _install_os_shutdown_guard(server: uvicorn.Server) -> "WindowsShutdownGuard | None":
|
||||
"""Install the OS-shutdown safety net (Windows only).
|
||||
|
||||
Returns the guard so the caller can ``stop()`` it on normal exit, or
|
||||
``None`` on platforms where no guard is needed.
|
||||
"""
|
||||
if not is_windows():
|
||||
return None
|
||||
|
||||
# ``shutdown_state`` is a leaf module — importing it does NOT pull in
|
||||
# ``ledgrab.main`` and its global stores. uvicorn loads ``main`` lazily
|
||||
# via the import string ``"ledgrab.main:app"`` once it starts serving.
|
||||
from ledgrab.shutdown_state import shutdown_complete
|
||||
|
||||
guard = WindowsShutdownGuard(
|
||||
on_shutdown=lambda: _request_shutdown(server),
|
||||
shutdown_complete=shutdown_complete,
|
||||
)
|
||||
if guard.start():
|
||||
logger.info("Windows shutdown guard installed")
|
||||
else:
|
||||
logger.warning("Windows shutdown guard failed to start")
|
||||
return guard
|
||||
|
||||
|
||||
def _install_signal_handlers(server: uvicorn.Server) -> None:
|
||||
"""Catch terminal/admin shutdown signals and trigger graceful exit.
|
||||
|
||||
Uvicorn already installs SIGINT/SIGTERM handlers when ``server.run()``
|
||||
is called on the main thread (the no-tray path). For the tray path,
|
||||
uvicorn runs on a background thread and skips signal installation, so
|
||||
we install our own here. SIGBREAK is Windows-specific and fires on
|
||||
Ctrl-Break and in some service-stop scenarios.
|
||||
"""
|
||||
|
||||
def _handler(signum, frame): # noqa: ANN001 - signal handler signature
|
||||
logger.warning("Signal %s received — requesting shutdown", signum)
|
||||
_request_shutdown(server)
|
||||
|
||||
candidates = ["SIGINT", "SIGTERM", "SIGBREAK"]
|
||||
for name in candidates:
|
||||
sig = getattr(signal, name, None)
|
||||
if sig is None:
|
||||
continue
|
||||
try:
|
||||
signal.signal(sig, _handler)
|
||||
except (ValueError, OSError) as e:
|
||||
# ValueError: not on main thread; OSError: signal not supported here.
|
||||
logger.debug("Could not install handler for %s: %s", name, e)
|
||||
|
||||
|
||||
def _force_tray() -> bool:
|
||||
"""Allow forcing tray on non-Windows via LEDGRAB_TRAY=1."""
|
||||
import os
|
||||
|
||||
@@ -27,6 +27,7 @@ from .routes.update import router as update_router
|
||||
from .routes.assets import router as assets_router
|
||||
from .routes.home_assistant import router as home_assistant_router
|
||||
from .routes.mqtt import router as mqtt_router
|
||||
from .routes.http_endpoints import router as http_endpoints_router
|
||||
from .routes.game_integration import router as game_integration_router
|
||||
from .routes.audio_processing_templates import router as audio_processing_templates_router
|
||||
from .routes.audio_filters import router as audio_filters_router
|
||||
@@ -59,6 +60,7 @@ router.include_router(update_router)
|
||||
router.include_router(assets_router)
|
||||
router.include_router(home_assistant_router)
|
||||
router.include_router(mqtt_router)
|
||||
router.include_router(http_endpoints_router)
|
||||
router.include_router(game_integration_router)
|
||||
router.include_router(audio_processing_templates_router)
|
||||
router.include_router(audio_filters_router)
|
||||
|
||||
@@ -11,14 +11,13 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||
|
||||
from ledgrab.config import get_config
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Security scheme for Bearer token
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
|
||||
|
||||
|
||||
def is_auth_enabled() -> bool:
|
||||
"""Return True when at least one API key is configured."""
|
||||
@@ -26,15 +25,15 @@ def is_auth_enabled() -> bool:
|
||||
|
||||
|
||||
def _is_loopback(host: str | None) -> bool:
|
||||
"""Return True when *host* is a loopback address."""
|
||||
"""Return True when *host* is a loopback address.
|
||||
|
||||
Delegates to :func:`ledgrab.utils.net_classify.is_loopback` so this
|
||||
auth gate, the SSRF guard in ``safe_source``, and the LAN-default
|
||||
inference in ``url_scheme`` share one classification source.
|
||||
"""
|
||||
if not host:
|
||||
return False
|
||||
# Strip IPv6 brackets and zone IDs
|
||||
h = host.strip().lower()
|
||||
if h.startswith("[") and h.endswith("]"):
|
||||
h = h[1:-1]
|
||||
h = h.split("%", 1)[0]
|
||||
return h in _LOOPBACK_HOSTS
|
||||
return _classify_is_loopback(host)
|
||||
|
||||
|
||||
def verify_api_key(
|
||||
@@ -142,6 +141,23 @@ def require_authenticated(label: str) -> None:
|
||||
WS_AUTH_CLOSE_CODE = 4401
|
||||
|
||||
|
||||
WS_ORIGIN_CLOSE_CODE = 4403
|
||||
"""Close code sent when a WebSocket request fails the Origin allowlist."""
|
||||
|
||||
|
||||
def _is_origin_allowed(origin: str | None, allowed: list[str]) -> bool:
|
||||
"""Return True when *origin* matches one of the configured CORS origins.
|
||||
|
||||
Non-browser clients (Python scripts, curl) don't send Origin — those are
|
||||
allowed through; the Bearer-token check on the auth handshake is the
|
||||
primary defence in that case. Browsers always set Origin, so this only
|
||||
blocks cross-site WebSocket connection attempts (CSWSH).
|
||||
"""
|
||||
if not origin:
|
||||
return True
|
||||
return origin in set(allowed or [])
|
||||
|
||||
|
||||
async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0) -> str | None:
|
||||
"""Accept the WebSocket, then perform first-message auth handshake.
|
||||
|
||||
@@ -152,6 +168,23 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
|
||||
Returns the caller label on success, ``None`` on failure (connection
|
||||
already closed).
|
||||
"""
|
||||
# Reject cross-site WebSocket attempts before accepting — a browser-based
|
||||
# attacker page cannot forge the Origin header, so an Origin mismatch is
|
||||
# a strong signal even before the token check. Non-browser clients
|
||||
# legitimately omit Origin; those fall through to the auth handshake.
|
||||
config = get_config()
|
||||
origin = websocket.headers.get("origin")
|
||||
if not _is_origin_allowed(origin, config.server.cors_origins):
|
||||
logger.warning(
|
||||
"Rejected WebSocket from origin %r (not in cors_origins)",
|
||||
origin,
|
||||
)
|
||||
try:
|
||||
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
await websocket.accept()
|
||||
label = await verify_ws_auth(websocket, timeout=timeout)
|
||||
if label is None:
|
||||
|
||||
@@ -37,6 +37,7 @@ from ledgrab.storage.game_integration_store import GameIntegrationStore
|
||||
from ledgrab.core.game_integration.event_bus import GameEventBus
|
||||
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
|
||||
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
|
||||
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
|
||||
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
|
||||
from ledgrab.storage.pattern_template_store import PatternTemplateStore
|
||||
|
||||
@@ -165,6 +166,10 @@ def get_mqtt_manager() -> MQTTManager:
|
||||
return _get("mqtt_manager", "MQTT manager")
|
||||
|
||||
|
||||
def get_http_endpoint_store() -> HTTPEndpointStore:
|
||||
return _get("http_endpoint_store", "HTTP endpoint store")
|
||||
|
||||
|
||||
def get_audio_processing_template_store() -> AudioProcessingTemplateStore:
|
||||
return _get("audio_processing_template_store", "Audio processing template store")
|
||||
|
||||
@@ -237,6 +242,7 @@ def init_dependencies(
|
||||
game_event_bus: GameEventBus | None = None,
|
||||
mqtt_store: MQTTSourceStore | None = None,
|
||||
mqtt_manager: MQTTManager | None = None,
|
||||
http_endpoint_store: HTTPEndpointStore | None = None,
|
||||
audio_processing_template_store: AudioProcessingTemplateStore | None = None,
|
||||
pattern_template_store: PatternTemplateStore | None = None,
|
||||
):
|
||||
@@ -272,6 +278,7 @@ def init_dependencies(
|
||||
"game_event_bus": game_event_bus,
|
||||
"mqtt_store": mqtt_store,
|
||||
"mqtt_manager": mqtt_manager,
|
||||
"http_endpoint_store": http_endpoint_store,
|
||||
"audio_processing_template_store": audio_processing_template_store,
|
||||
"pattern_template_store": pattern_template_store,
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ from ledgrab.api.schemas.assets import (
|
||||
from ledgrab.config import get_config
|
||||
from ledgrab.storage.asset_store import AssetStore
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils import get_logger, read_upload_capped
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -93,10 +93,11 @@ async def upload_asset(
|
||||
config = get_config()
|
||||
max_size = getattr(getattr(config, "assets", None), "max_file_size_mb", 50) * 1024 * 1024
|
||||
|
||||
data = await file.read()
|
||||
if len(data) > max_size:
|
||||
try:
|
||||
data = await read_upload_capped(file, max_size)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
status_code=413,
|
||||
detail=f"File too large (max {max_size // (1024 * 1024)} MB)",
|
||||
)
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from ledgrab.storage.automation import (
|
||||
ApplicationRule,
|
||||
DisplayStateRule,
|
||||
HomeAssistantRule,
|
||||
HTTPPollRule,
|
||||
MQTTRule,
|
||||
Rule,
|
||||
StartupRule,
|
||||
@@ -75,6 +76,11 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
|
||||
state=s.state or "",
|
||||
match_mode=s.match_mode or "exact",
|
||||
),
|
||||
"http_poll": lambda: HTTPPollRule(
|
||||
value_source_id=s.value_source_id or "",
|
||||
operator=s.operator or "equals",
|
||||
value=s.value or "",
|
||||
),
|
||||
}
|
||||
factory = _SCHEMA_TO_RULE.get(s.rule_type)
|
||||
if factory is None:
|
||||
|
||||
@@ -28,7 +28,7 @@ from ledgrab.config import get_config
|
||||
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
||||
from ledgrab.storage.asset_store import AssetStore
|
||||
from ledgrab.storage.database import Database, freeze_writes
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils import get_logger, read_upload_capped
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -133,9 +133,11 @@ async def restore_config(
|
||||
because restore replaces all configuration including secrets).
|
||||
"""
|
||||
require_authenticated(auth)
|
||||
raw = await file.read()
|
||||
if len(raw) > 200 * 1024 * 1024: # 200 MB limit (ZIP may contain assets)
|
||||
raise HTTPException(status_code=400, detail="Backup file too large (max 200 MB)")
|
||||
_MAX_BACKUP_BYTES = 200 * 1024 * 1024 # 200 MB (ZIP may contain assets)
|
||||
try:
|
||||
raw = await read_upload_capped(file, _MAX_BACKUP_BYTES)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=413, detail="Backup file too large (max 200 MB)")
|
||||
|
||||
if len(raw) < 100:
|
||||
raise HTTPException(status_code=400, detail="File too small to be a valid backup")
|
||||
|
||||
@@ -9,6 +9,7 @@ from ledgrab.api.schemas.color_strip_sources import (
|
||||
CompositeCSSResponse,
|
||||
DaylightCSSResponse,
|
||||
EffectCSSResponse,
|
||||
GameEventCSSResponse,
|
||||
GradientCSSResponse,
|
||||
KeyColorsCSSResponse,
|
||||
MappedCSSResponse,
|
||||
@@ -17,7 +18,7 @@ from ledgrab.api.schemas.color_strip_sources import (
|
||||
PictureAdvancedCSSResponse,
|
||||
PictureCSSResponse,
|
||||
ProcessedCSSResponse,
|
||||
StaticCSSResponse,
|
||||
SingleColorCSSResponse,
|
||||
WeatherCSSResponse,
|
||||
)
|
||||
from ledgrab.api.schemas.devices import Calibration as CalibrationSchema
|
||||
@@ -26,22 +27,7 @@ from ledgrab.core.capture.calibration import (
|
||||
calibration_to_dict,
|
||||
)
|
||||
from ledgrab.storage.color_strip_source import (
|
||||
AdvancedPictureColorStripSource,
|
||||
ApiInputColorStripSource,
|
||||
AudioColorStripSource,
|
||||
CandlelightColorStripSource,
|
||||
CompositeColorStripSource,
|
||||
DaylightColorStripSource,
|
||||
EffectColorStripSource,
|
||||
GradientColorStripSource,
|
||||
KeyColorsColorStripSource,
|
||||
MappedColorStripSource,
|
||||
MathWaveColorStripSource,
|
||||
NotificationColorStripSource,
|
||||
PictureColorStripSource,
|
||||
ProcessedColorStripSource,
|
||||
StaticColorStripSource,
|
||||
WeatherColorStripSource,
|
||||
_SOURCE_TYPE_MAP as _STORAGE_TYPE_MAP,
|
||||
)
|
||||
from ledgrab.storage.picture_source import (
|
||||
ProcessedPictureSource,
|
||||
@@ -94,34 +80,46 @@ def _stops_schema(source) -> list[ColorStopSchema] | None:
|
||||
return None
|
||||
|
||||
|
||||
# Maps storage class → response builder lambda.
|
||||
# Maps ``source_type`` string → response builder.
|
||||
#
|
||||
# Keying by source_type (rather than type(source)) lets the import-time
|
||||
# coverage check use the storage registry's keys directly, with no
|
||||
# inversion or duplicate-class handling for legacy aliases.
|
||||
_RESPONSE_MAP: dict = {
|
||||
PictureColorStripSource: lambda s, kw: PictureCSSResponse(
|
||||
"picture": lambda s, kw: PictureCSSResponse(
|
||||
**kw,
|
||||
picture_source_id=s.picture_source_id,
|
||||
smoothing=s.smoothing.to_dict(),
|
||||
interpolation_mode=s.interpolation_mode,
|
||||
calibration=_calibration_schema(s),
|
||||
),
|
||||
AdvancedPictureColorStripSource: lambda s, kw: PictureAdvancedCSSResponse(
|
||||
"picture_advanced": lambda s, kw: PictureAdvancedCSSResponse(
|
||||
**kw,
|
||||
smoothing=s.smoothing.to_dict(),
|
||||
interpolation_mode=s.interpolation_mode,
|
||||
calibration=_calibration_schema(s),
|
||||
),
|
||||
StaticColorStripSource: lambda s, kw: StaticCSSResponse(
|
||||
"single_color": lambda s, kw: SingleColorCSSResponse(
|
||||
**kw,
|
||||
color=s.color.to_dict(),
|
||||
animation=s.animation,
|
||||
),
|
||||
GradientColorStripSource: lambda s, kw: GradientCSSResponse(
|
||||
# Legacy alias: pre-rename rows used "static"; the data migration rewrites
|
||||
# them on first store load but a stale in-flight instance would still
|
||||
# carry source_type='static' until the next reload.
|
||||
"static": lambda s, kw: SingleColorCSSResponse(
|
||||
**kw,
|
||||
color=s.color.to_dict(),
|
||||
animation=s.animation,
|
||||
),
|
||||
"gradient": lambda s, kw: GradientCSSResponse(
|
||||
**kw,
|
||||
stops=_stops_schema(s),
|
||||
animation=s.animation,
|
||||
easing=s.easing,
|
||||
gradient_id=s.gradient_id,
|
||||
),
|
||||
EffectColorStripSource: lambda s, kw: EffectCSSResponse(
|
||||
"effect": lambda s, kw: EffectCSSResponse(
|
||||
**kw,
|
||||
effect_type=s.effect_type,
|
||||
palette=s.palette,
|
||||
@@ -132,15 +130,15 @@ _RESPONSE_MAP: dict = {
|
||||
mirror=s.mirror,
|
||||
custom_palette=s.custom_palette,
|
||||
),
|
||||
CompositeColorStripSource: lambda s, kw: CompositeCSSResponse(
|
||||
"composite": lambda s, kw: CompositeCSSResponse(
|
||||
**kw,
|
||||
layers=[dict(layer) for layer in s.layers],
|
||||
),
|
||||
MappedColorStripSource: lambda s, kw: MappedCSSResponse(
|
||||
"mapped": lambda s, kw: MappedCSSResponse(
|
||||
**kw,
|
||||
zones=[dict(z) for z in s.zones],
|
||||
),
|
||||
AudioColorStripSource: lambda s, kw: AudioCSSResponse(
|
||||
"audio": lambda s, kw: AudioCSSResponse(
|
||||
**kw,
|
||||
visualization_mode=s.visualization_mode,
|
||||
audio_source_id=s.audio_source_id,
|
||||
@@ -153,13 +151,13 @@ _RESPONSE_MAP: dict = {
|
||||
mirror=s.mirror,
|
||||
beat_decay=s.beat_decay.to_dict(),
|
||||
),
|
||||
ApiInputColorStripSource: lambda s, kw: ApiInputCSSResponse(
|
||||
"api_input": lambda s, kw: ApiInputCSSResponse(
|
||||
**kw,
|
||||
fallback_color=s.fallback_color.to_dict(),
|
||||
timeout=s.timeout.to_dict(),
|
||||
interpolation=s.interpolation,
|
||||
),
|
||||
NotificationColorStripSource: lambda s, kw: NotificationCSSResponse(
|
||||
"notification": lambda s, kw: NotificationCSSResponse(
|
||||
**kw,
|
||||
notification_effect=s.notification_effect,
|
||||
duration_ms=s.duration_ms.to_dict(),
|
||||
@@ -172,14 +170,14 @@ _RESPONSE_MAP: dict = {
|
||||
sound_volume=s.sound_volume.to_dict(),
|
||||
app_sounds=dict(s.app_sounds),
|
||||
),
|
||||
DaylightColorStripSource: lambda s, kw: DaylightCSSResponse(
|
||||
"daylight": lambda s, kw: DaylightCSSResponse(
|
||||
**kw,
|
||||
speed=s.speed.to_dict(),
|
||||
use_real_time=s.use_real_time,
|
||||
latitude=s.latitude,
|
||||
longitude=s.longitude,
|
||||
),
|
||||
CandlelightColorStripSource: lambda s, kw: CandlelightCSSResponse(
|
||||
"candlelight": lambda s, kw: CandlelightCSSResponse(
|
||||
**kw,
|
||||
color=s.color.to_dict(),
|
||||
intensity=s.intensity.to_dict(),
|
||||
@@ -188,18 +186,18 @@ _RESPONSE_MAP: dict = {
|
||||
wind_strength=s.wind_strength.to_dict(),
|
||||
candle_type=s.candle_type,
|
||||
),
|
||||
ProcessedColorStripSource: lambda s, kw: ProcessedCSSResponse(
|
||||
"processed": lambda s, kw: ProcessedCSSResponse(
|
||||
**kw,
|
||||
input_source_id=s.input_source_id,
|
||||
processing_template_id=s.processing_template_id,
|
||||
),
|
||||
WeatherColorStripSource: lambda s, kw: WeatherCSSResponse(
|
||||
"weather": lambda s, kw: WeatherCSSResponse(
|
||||
**kw,
|
||||
weather_source_id=s.weather_source_id,
|
||||
speed=s.speed.to_dict(),
|
||||
temperature_influence=s.temperature_influence.to_dict(),
|
||||
),
|
||||
KeyColorsColorStripSource: lambda s, kw: KeyColorsCSSResponse(
|
||||
"key_colors": lambda s, kw: KeyColorsCSSResponse(
|
||||
**kw,
|
||||
picture_source_id=s.picture_source_id,
|
||||
rectangles=[r.to_dict() for r in s.rectangles],
|
||||
@@ -207,28 +205,67 @@ _RESPONSE_MAP: dict = {
|
||||
smoothing=s.smoothing.to_dict(),
|
||||
brightness=s.brightness.to_dict(),
|
||||
),
|
||||
MathWaveColorStripSource: lambda s, kw: MathWaveCSSResponse(
|
||||
"math_wave": lambda s, kw: MathWaveCSSResponse(
|
||||
**kw,
|
||||
waves=s.waves,
|
||||
speed=s.speed.to_dict(),
|
||||
gradient_id=s.gradient_id,
|
||||
),
|
||||
"game_event": lambda s, kw: GameEventCSSResponse(
|
||||
**kw,
|
||||
game_integration_id=s.game_integration_id,
|
||||
idle_color=s.idle_color.to_dict(),
|
||||
event_mappings=[dict(m) for m in s.event_mappings],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _assert_response_map_coverage() -> None:
|
||||
"""Verify _RESPONSE_MAP has a builder for every kind in storage's registry.
|
||||
|
||||
Runs at module import. Surfaces missing builders eagerly instead of
|
||||
letting a request fall through to a silent / wrong response shape.
|
||||
|
||||
Contract note
|
||||
-------------
|
||||
This check is **symmetric** (``_RESPONSE_MAP keys == storage_kinds``)
|
||||
because every kind — sharable or not — needs a response shape. The
|
||||
sister assertion in
|
||||
``core/processing/color_strip_kinds.py::_assert_stream_kind_coverage``
|
||||
is asymmetric because sharable kinds construct their streams via a
|
||||
different path. Adding a new kind requires keeping all three registries
|
||||
aligned: storage's ``_SOURCE_TYPE_MAP``, this ``_RESPONSE_MAP``, and
|
||||
either ``STREAM_BUILDERS`` or ``SHARABLE_KINDS``.
|
||||
"""
|
||||
storage_kinds = set(_STORAGE_TYPE_MAP.keys())
|
||||
builder_kinds = set(_RESPONSE_MAP.keys())
|
||||
missing = storage_kinds - builder_kinds
|
||||
extra = builder_kinds - storage_kinds
|
||||
if missing or extra:
|
||||
problems = []
|
||||
if missing:
|
||||
problems.append(f"missing builders for: {sorted(missing)}")
|
||||
if extra:
|
||||
problems.append(f"unregistered kinds in _RESPONSE_MAP: {sorted(extra)}")
|
||||
raise RuntimeError(
|
||||
"_RESPONSE_MAP is out of sync with storage._SOURCE_TYPE_MAP: " + "; ".join(problems)
|
||||
)
|
||||
|
||||
|
||||
_assert_response_map_coverage()
|
||||
|
||||
|
||||
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
|
||||
"""Convert a ColorStripSource to the matching per-type response schema."""
|
||||
kw = _common_response_kwargs(source, overlay_active)
|
||||
builder = _RESPONSE_MAP.get(type(source))
|
||||
builder = _RESPONSE_MAP.get(source.source_type)
|
||||
if builder is None:
|
||||
# Fallback: use to_dict() and build a PictureCSSResponse
|
||||
logger.warning("No response builder for %s, falling back", type(source).__name__)
|
||||
return PictureCSSResponse(
|
||||
**kw,
|
||||
picture_source_id="",
|
||||
smoothing=0.3,
|
||||
interpolation_mode="average",
|
||||
calibration=None,
|
||||
# Coverage is asserted at import time, so reaching this branch means a
|
||||
# source was loaded with a source_type that is not registered.
|
||||
# Surface the bug instead of silently returning a wrong-shaped response.
|
||||
raise RuntimeError(
|
||||
f"No CSS response builder registered for source_type "
|
||||
f"{source.source_type!r} (class={type(source).__name__})"
|
||||
)
|
||||
return builder(source, kw)
|
||||
|
||||
|
||||
@@ -29,12 +29,20 @@ router = APIRouter()
|
||||
|
||||
|
||||
_PREVIEW_ALLOWED_TYPES = {
|
||||
"static",
|
||||
"single_color",
|
||||
"gradient",
|
||||
"effect",
|
||||
"daylight",
|
||||
"candlelight",
|
||||
"notification",
|
||||
"audio",
|
||||
"math_wave",
|
||||
"weather",
|
||||
"game_event",
|
||||
"api_input",
|
||||
"mapped",
|
||||
"composite",
|
||||
"processed",
|
||||
}
|
||||
|
||||
|
||||
@@ -89,13 +97,65 @@ async def preview_color_strip_ws(
|
||||
return ColorStripSource.from_dict(config)
|
||||
|
||||
def _create_stream(source):
|
||||
"""Instantiate and start the appropriate stream class for *source*."""
|
||||
from ledgrab.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP
|
||||
"""Instantiate and start the appropriate stream class for *source*.
|
||||
|
||||
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
|
||||
if not stream_cls:
|
||||
raise ValueError(f"Unsupported preview source_type: {source.source_type}")
|
||||
s = stream_cls(source)
|
||||
Delegates the per-kind dispatch to ``color_strip_kinds.build_stream``
|
||||
so this preview path and the production ``ColorStripStreamManager``
|
||||
share a single registry. Per-kind dependencies (CSPT store, audio
|
||||
stores, weather manager, …) are gathered into a ``StreamDeps`` bag.
|
||||
|
||||
FastAPI-DI providers raise ``RuntimeError`` when they aren't wired,
|
||||
so we resolve each one through ``_safe`` and pass ``None`` on
|
||||
failure. The per-kind builder will still see a clear error if a
|
||||
truly-required dep is missing for that kind, but unrelated previews
|
||||
(e.g. a ``single_color`` preview on a fresh install where the CSPT
|
||||
store isn't initialized yet) keep working.
|
||||
"""
|
||||
from ledgrab.api.dependencies import (
|
||||
get_audio_processing_template_store,
|
||||
get_audio_source_store,
|
||||
get_audio_template_store,
|
||||
get_cspt_store,
|
||||
)
|
||||
from ledgrab.core.processing.color_strip_kinds import StreamDeps, build_stream
|
||||
|
||||
def _safe(getter):
|
||||
try:
|
||||
return getter()
|
||||
except RuntimeError as e:
|
||||
logger.debug("Preview dep not available (%s): %s", getter.__name__, e)
|
||||
return None
|
||||
|
||||
mgr = get_processor_manager()
|
||||
csm = mgr.color_strip_stream_manager
|
||||
|
||||
# The game-event bus is optional in preview contexts.
|
||||
try:
|
||||
from ledgrab.api.dependencies import get_game_event_bus
|
||||
|
||||
game_event_bus = get_game_event_bus()
|
||||
except RuntimeError as e:
|
||||
logger.debug("Preview: no game event bus available: %s", e)
|
||||
game_event_bus = None
|
||||
|
||||
deps = StreamDeps(
|
||||
css_manager=csm,
|
||||
value_stream_manager=mgr.value_stream_manager,
|
||||
cspt_store=_safe(get_cspt_store),
|
||||
weather_manager=mgr.weather_manager,
|
||||
audio_capture_manager=mgr.audio_capture_manager,
|
||||
audio_source_store=_safe(get_audio_source_store),
|
||||
audio_template_store=_safe(get_audio_template_store),
|
||||
audio_processing_template_store=_safe(get_audio_processing_template_store),
|
||||
game_event_bus=game_event_bus,
|
||||
depth=0,
|
||||
)
|
||||
try:
|
||||
s = build_stream(source, deps)
|
||||
except ValueError as e:
|
||||
# Preserve the registry's original detail so the API consumer
|
||||
# sees which kind was rejected, not just a generic message.
|
||||
raise ValueError(f"Unsupported preview source_type: {e}") from e
|
||||
# Inject gradient store for palette resolution
|
||||
if hasattr(s, "set_gradient_store"):
|
||||
try:
|
||||
@@ -121,7 +181,24 @@ async def preview_color_strip_ws(
|
||||
cid = None
|
||||
else:
|
||||
cid = None
|
||||
s.start()
|
||||
# Start the stream; if start() raises, release any resources we
|
||||
# already acquired (clock + anything the stream itself grabbed in
|
||||
# its __init__) so we don't leak refs across failed previews.
|
||||
try:
|
||||
s.start()
|
||||
except Exception:
|
||||
try:
|
||||
s.stop()
|
||||
except Exception as e_stop:
|
||||
logger.exception("unexpected in start-failure rollback s.stop: %s", e_stop)
|
||||
if cid:
|
||||
scm = _get_sync_clock_manager()
|
||||
if scm:
|
||||
try:
|
||||
scm.release(cid)
|
||||
except Exception as e_rel:
|
||||
logger.exception("unexpected in start-failure clock release: %s", e_rel)
|
||||
raise
|
||||
return s, cid
|
||||
|
||||
def _stop_stream(s, cid):
|
||||
@@ -222,10 +299,24 @@ async def preview_color_strip_ws(
|
||||
continue
|
||||
new_source = _build_source(new_config)
|
||||
if new_type != current_source_type:
|
||||
# Source type changed — recreate stream
|
||||
# Source type changed — stop the old stream first, then
|
||||
# build the new one. If the rebuild fails, drop the
|
||||
# reference so the frame loop doesn't keep polling a
|
||||
# stopped stream and the finally-block doesn't double-stop.
|
||||
_stop_stream(stream, clock_id)
|
||||
stream, clock_id = _create_stream(new_source)
|
||||
current_source_type = new_type
|
||||
stream, clock_id = None, None
|
||||
try:
|
||||
stream, clock_id = _create_stream(new_source)
|
||||
current_source_type = new_type
|
||||
except Exception as rebuild_err:
|
||||
logger.error(
|
||||
f"Preview WS: failed to rebuild stream for new type {new_type}: {rebuild_err}"
|
||||
)
|
||||
await websocket.send_text(
|
||||
_json.dumps({"type": "error", "detail": str(rebuild_err)})
|
||||
)
|
||||
await websocket.close(code=4003, reason=str(rebuild_err))
|
||||
return
|
||||
else:
|
||||
stream.update_source(new_source)
|
||||
if hasattr(stream, "configure"):
|
||||
@@ -236,12 +327,15 @@ async def preview_color_strip_ws(
|
||||
await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)}))
|
||||
|
||||
# Send frame
|
||||
colors = stream.get_latest_colors()
|
||||
if colors is not None:
|
||||
await websocket.send_bytes(colors.tobytes())
|
||||
else:
|
||||
# Stream hasn't produced a frame yet — send black
|
||||
if stream is None:
|
||||
await websocket.send_bytes(b"\x00" * led_count * 3)
|
||||
else:
|
||||
colors = stream.get_latest_colors()
|
||||
if colors is not None:
|
||||
await websocket.send_bytes(colors.tobytes())
|
||||
else:
|
||||
# Stream hasn't produced a frame yet — send black
|
||||
await websocket.send_bytes(b"\x00" * led_count * 3)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
@@ -334,8 +428,17 @@ async def css_api_input_ws(
|
||||
continue
|
||||
|
||||
elif "bytes" in message:
|
||||
# Binary frame: raw RGBRGB... bytes (3 bytes per LED)
|
||||
# Binary frame: raw RGBRGB... bytes (3 bytes per LED).
|
||||
# Cap to a generous upper bound on the LED count — a hostile
|
||||
# client could otherwise stream 100 MB frames and OOM the
|
||||
# server before any application logic ran.
|
||||
raw_bytes = message["bytes"]
|
||||
_MAX_BINARY_LEDS = 8192
|
||||
if len(raw_bytes) > _MAX_BINARY_LEDS * 3:
|
||||
await websocket.send_json(
|
||||
{"error": f"Binary frame too large (max {_MAX_BINARY_LEDS} LEDs)"}
|
||||
)
|
||||
continue
|
||||
if len(raw_bytes) % 3 != 0:
|
||||
await websocket.send_json({"error": "Binary data must be multiple of 3 bytes"})
|
||||
continue
|
||||
|
||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock
|
||||
|
||||
from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.core.devices.led_client import (
|
||||
PairingNotReady,
|
||||
get_all_providers,
|
||||
get_device_capabilities,
|
||||
get_provider,
|
||||
@@ -26,18 +27,45 @@ from ledgrab.api.schemas.devices import (
|
||||
DiscoverDevicesResponse,
|
||||
OpenRGBZoneResponse,
|
||||
OpenRGBZonesResponse,
|
||||
PairDeviceRequest,
|
||||
PairDeviceResponse,
|
||||
PowerRequest,
|
||||
)
|
||||
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||
from ledgrab.storage import DeviceStore
|
||||
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.url_scheme import infer_http_scheme
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _sanitize_url_for_log(url: str) -> str:
|
||||
"""Strip userinfo + fragment from a URL so secrets don't reach logs.
|
||||
|
||||
The pair endpoint receives a user-supplied URL on every call; if a
|
||||
future driver ever accepts ``scheme://user:pass@host`` form the
|
||||
credentials would land in logs without this guard.
|
||||
"""
|
||||
if not url:
|
||||
return ""
|
||||
try:
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
# urlparse stores userinfo in `netloc`; rebuild without it.
|
||||
if parsed.hostname:
|
||||
netloc = parsed.hostname
|
||||
if parsed.port:
|
||||
netloc = f"{netloc}:{parsed.port}"
|
||||
return urlunparse((parsed.scheme, netloc, parsed.path, parsed.params, parsed.query, ""))
|
||||
except ValueError:
|
||||
pass
|
||||
return url
|
||||
|
||||
|
||||
def _device_to_response(device) -> DeviceResponse:
|
||||
"""Convert a Device to DeviceResponse."""
|
||||
return DeviceResponse(
|
||||
@@ -57,11 +85,20 @@ def _device_to_response(device) -> DeviceResponse:
|
||||
dmx_protocol=device.dmx_protocol,
|
||||
dmx_start_universe=device.dmx_start_universe,
|
||||
dmx_start_channel=device.dmx_start_channel,
|
||||
ddp_port=device.ddp_port,
|
||||
ddp_destination_id=device.ddp_destination_id,
|
||||
ddp_color_order=device.ddp_color_order,
|
||||
espnow_peer_mac=device.espnow_peer_mac,
|
||||
espnow_channel=device.espnow_channel,
|
||||
hue_username=device.hue_username,
|
||||
hue_client_key=device.hue_client_key,
|
||||
hue_paired=bool(device.hue_username and device.hue_client_key),
|
||||
hue_entertainment_group_id=device.hue_entertainment_group_id,
|
||||
yeelight_min_interval_ms=device.yeelight_min_interval_ms,
|
||||
wiz_min_interval_ms=device.wiz_min_interval_ms,
|
||||
lifx_min_interval_ms=device.lifx_min_interval_ms,
|
||||
govee_min_interval_ms=device.govee_min_interval_ms,
|
||||
opc_channel=device.opc_channel,
|
||||
nanoleaf_paired=bool(device.nanoleaf_token),
|
||||
nanoleaf_min_interval_ms=device.nanoleaf_min_interval_ms,
|
||||
spi_speed_hz=device.spi_speed_hz,
|
||||
spi_led_type=device.spi_led_type,
|
||||
chroma_device_type=device.chroma_device_type,
|
||||
@@ -134,6 +171,8 @@ async def create_device(
|
||||
detail="URL is required for non-group device types.",
|
||||
)
|
||||
device_url = device_data.url.rstrip("/")
|
||||
if device_type == "wled":
|
||||
device_url = infer_http_scheme(device_url)
|
||||
|
||||
# ── Non-group: validate via provider ──
|
||||
if device_type != "group":
|
||||
@@ -168,9 +207,19 @@ async def create_device(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
# Don't leak the raw exception text — it can carry stack
|
||||
# frames, host headers, or other internals that aren't safe
|
||||
# to echo. Log with full context, return a generic message.
|
||||
logger.warning(
|
||||
"Failed to validate %s device at %s: %s",
|
||||
device_type,
|
||||
device_url,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Failed to connect to {device_type} device at {device_url}: {e}",
|
||||
detail=f"Failed to connect to {device_type} device at {device_url}.",
|
||||
)
|
||||
|
||||
# Resolve auto_shutdown default: False for all types
|
||||
@@ -181,7 +230,7 @@ async def create_device(
|
||||
# Create device in storage
|
||||
device = store.create_device(
|
||||
name=device_data.name,
|
||||
url=device_data.url,
|
||||
url=device_url,
|
||||
led_count=led_count,
|
||||
device_type=device_type,
|
||||
baud_rate=device_data.baud_rate,
|
||||
@@ -193,11 +242,45 @@ async def create_device(
|
||||
dmx_protocol=device_data.dmx_protocol or "artnet",
|
||||
dmx_start_universe=device_data.dmx_start_universe or 0,
|
||||
dmx_start_channel=device_data.dmx_start_channel or 1,
|
||||
ddp_port=device_data.ddp_port or 0,
|
||||
ddp_destination_id=(
|
||||
device_data.ddp_destination_id if device_data.ddp_destination_id is not None else 1
|
||||
),
|
||||
ddp_color_order=(
|
||||
device_data.ddp_color_order if device_data.ddp_color_order is not None else 1
|
||||
),
|
||||
espnow_peer_mac=device_data.espnow_peer_mac or "",
|
||||
espnow_channel=device_data.espnow_channel or 1,
|
||||
hue_username=device_data.hue_username or "",
|
||||
hue_client_key=device_data.hue_client_key or "",
|
||||
hue_entertainment_group_id=device_data.hue_entertainment_group_id or "",
|
||||
yeelight_min_interval_ms=(
|
||||
device_data.yeelight_min_interval_ms
|
||||
if device_data.yeelight_min_interval_ms is not None
|
||||
else 500
|
||||
),
|
||||
wiz_min_interval_ms=(
|
||||
device_data.wiz_min_interval_ms
|
||||
if device_data.wiz_min_interval_ms is not None
|
||||
else 50
|
||||
),
|
||||
lifx_min_interval_ms=(
|
||||
device_data.lifx_min_interval_ms
|
||||
if device_data.lifx_min_interval_ms is not None
|
||||
else 50
|
||||
),
|
||||
govee_min_interval_ms=(
|
||||
device_data.govee_min_interval_ms
|
||||
if device_data.govee_min_interval_ms is not None
|
||||
else 50
|
||||
),
|
||||
opc_channel=(device_data.opc_channel if device_data.opc_channel is not None else 0),
|
||||
nanoleaf_token=device_data.nanoleaf_token or "",
|
||||
nanoleaf_min_interval_ms=(
|
||||
device_data.nanoleaf_min_interval_ms
|
||||
if device_data.nanoleaf_min_interval_ms is not None
|
||||
else 100
|
||||
),
|
||||
spi_speed_hz=device_data.spi_speed_hz or 800000,
|
||||
spi_led_type=device_data.spi_led_type or "WS2812B",
|
||||
chroma_device_type=device_data.chroma_device_type or "chromalink",
|
||||
@@ -233,6 +316,79 @@ async def create_device(
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/devices/pair",
|
||||
response_model=PairDeviceResponse,
|
||||
tags=["Devices"],
|
||||
)
|
||||
async def pair_device(
|
||||
body: PairDeviceRequest,
|
||||
_auth: AuthRequired,
|
||||
):
|
||||
"""Run a pairing handshake against a device before creating it.
|
||||
|
||||
The frontend opens this endpoint after the user has performed the
|
||||
device's physical pairing action (e.g. held the power button for 5s).
|
||||
The response carries provider-specific fields the caller must include
|
||||
in the subsequent ``POST /api/v1/devices`` body.
|
||||
|
||||
Status codes:
|
||||
200 paired — fields returned
|
||||
400 unknown device type, or device type does not support pairing
|
||||
409 device not ready — user must perform the physical action
|
||||
(or retry, e.g. the pairing window timed out)
|
||||
422 invalid URL or device configuration
|
||||
"""
|
||||
try:
|
||||
provider = get_provider(body.device_type)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown device type: {body.device_type}")
|
||||
|
||||
try:
|
||||
fields = await provider.pair_device(body.url)
|
||||
except NotImplementedError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Device type {body.device_type!r} does not support pairing",
|
||||
)
|
||||
except PairingNotReady as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
except Exception as exc:
|
||||
# Strip userinfo before logging so a `scheme://user:pass@host` URL
|
||||
# never lands in the logs (no shipped driver uses userinfo today,
|
||||
# but the pattern is a foot-gun for the next driver author --
|
||||
# caught by review MEDIUM #9). Also keep exc_info=False so a
|
||||
# provider stack trace that may include response bytes from a
|
||||
# hostile receiver doesn't end up in the file either.
|
||||
safe_url = _sanitize_url_for_log(body.url)
|
||||
logger.warning(
|
||||
"Pairing failed for %s at %s: %s: %s",
|
||||
body.device_type,
|
||||
safe_url,
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Pairing failed for {body.device_type} at {safe_url}.",
|
||||
)
|
||||
|
||||
if not isinstance(fields, dict):
|
||||
logger.warning(
|
||||
"Provider %s.pair_device returned %r (expected dict)",
|
||||
body.device_type,
|
||||
type(fields).__name__,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Provider {body.device_type!r} returned malformed pairing result",
|
||||
)
|
||||
|
||||
return PairDeviceResponse(fields=fields)
|
||||
|
||||
|
||||
@router.get("/api/v1/devices", response_model=DeviceListResponse, tags=["Devices"])
|
||||
async def list_devices(
|
||||
_auth: AuthRequired,
|
||||
@@ -266,11 +422,20 @@ async def discover_devices(
|
||||
raise HTTPException(status_code=400, detail=f"Unknown device type: {device_type}")
|
||||
discovered = await provider.discover(timeout=capped_timeout)
|
||||
else:
|
||||
# Discover from all providers in parallel
|
||||
# Discover from all providers in parallel. Discovery is best-effort:
|
||||
# one provider failing (firewall, missing dep, mDNS race) must not
|
||||
# take the entire scan down, so collect exceptions instead of
|
||||
# raising and log them individually.
|
||||
providers = get_all_providers()
|
||||
discover_tasks = [p.discover(timeout=capped_timeout) for p in providers.values()]
|
||||
all_results = await asyncio.gather(*discover_tasks)
|
||||
discovered = [d for batch in all_results for d in batch]
|
||||
provider_items = list(providers.items())
|
||||
discover_tasks = [p.discover(timeout=capped_timeout) for _, p in provider_items]
|
||||
all_results = await asyncio.gather(*discover_tasks, return_exceptions=True)
|
||||
discovered = []
|
||||
for (name, _), result in zip(provider_items, all_results):
|
||||
if isinstance(result, BaseException):
|
||||
logger.warning("Discovery failed for provider %s: %s", name, result)
|
||||
continue
|
||||
discovered.extend(result)
|
||||
elapsed_ms = (time.time() - start) * 1000
|
||||
|
||||
existing_urls = {d.url.rstrip("/").lower() for d in store.get_all_devices()}
|
||||
@@ -385,6 +550,26 @@ async def update_device(
|
||||
existing = store.get_device(device_id)
|
||||
is_group = existing.device_type == "group"
|
||||
|
||||
# Normalize URL the same way we do on create:
|
||||
# * always rstrip trailing slashes (so PUT-with-trailing-/ matches
|
||||
# POST-with-trailing-/ in the stored value -- caught by review HIGH #6)
|
||||
# * only WLED gets http/https scheme inference; other schemes
|
||||
# (yeelight://, lifx://, opc://, ddp://, …) pass through.
|
||||
# Done via a local rather than mutating the request DTO so the
|
||||
# input is preserved for any future caller that inspects it.
|
||||
normalized_url = update_data.url
|
||||
if update_data.url:
|
||||
normalized_url = update_data.url.rstrip("/")
|
||||
if existing.device_type == "wled":
|
||||
inferred = infer_http_scheme(normalized_url)
|
||||
if inferred != normalized_url:
|
||||
logger.debug("Inferred WLED URL scheme: %r -> %r", normalized_url, inferred)
|
||||
normalized_url = inferred
|
||||
|
||||
# Group-only field overrides (led_count auto-recompute) are accumulated
|
||||
# here too so the update_data Pydantic model is not mutated in place.
|
||||
normalized_led_count = update_data.led_count
|
||||
|
||||
if is_group:
|
||||
new_children = update_data.group_device_ids
|
||||
new_mode = update_data.group_mode or existing.group_mode
|
||||
@@ -405,20 +590,20 @@ async def update_device(
|
||||
|
||||
# Auto-recompute led_count for sequence mode
|
||||
if effective_mode == "sequence":
|
||||
update_data.led_count = store.resolve_group_led_count(effective_children)
|
||||
normalized_led_count = store.resolve_group_led_count(effective_children)
|
||||
elif (
|
||||
update_data.led_count is None
|
||||
normalized_led_count is None
|
||||
and new_mode == "independent"
|
||||
and new_children is not None
|
||||
):
|
||||
update_data.led_count = store.resolve_group_max_led_count(effective_children)
|
||||
normalized_led_count = store.resolve_group_max_led_count(effective_children)
|
||||
|
||||
device = store.update_device(
|
||||
device_id=device_id,
|
||||
name=update_data.name,
|
||||
url=update_data.url,
|
||||
url=normalized_url,
|
||||
enabled=update_data.enabled,
|
||||
led_count=update_data.led_count,
|
||||
led_count=normalized_led_count,
|
||||
baud_rate=update_data.baud_rate,
|
||||
auto_shutdown=update_data.auto_shutdown,
|
||||
send_latency_ms=update_data.send_latency_ms,
|
||||
@@ -428,11 +613,21 @@ async def update_device(
|
||||
dmx_protocol=update_data.dmx_protocol,
|
||||
dmx_start_universe=update_data.dmx_start_universe,
|
||||
dmx_start_channel=update_data.dmx_start_channel,
|
||||
ddp_port=update_data.ddp_port,
|
||||
ddp_destination_id=update_data.ddp_destination_id,
|
||||
ddp_color_order=update_data.ddp_color_order,
|
||||
espnow_peer_mac=update_data.espnow_peer_mac,
|
||||
espnow_channel=update_data.espnow_channel,
|
||||
hue_username=update_data.hue_username,
|
||||
hue_client_key=update_data.hue_client_key,
|
||||
hue_entertainment_group_id=update_data.hue_entertainment_group_id,
|
||||
yeelight_min_interval_ms=update_data.yeelight_min_interval_ms,
|
||||
wiz_min_interval_ms=update_data.wiz_min_interval_ms,
|
||||
lifx_min_interval_ms=update_data.lifx_min_interval_ms,
|
||||
govee_min_interval_ms=update_data.govee_min_interval_ms,
|
||||
opc_channel=update_data.opc_channel,
|
||||
nanoleaf_token=update_data.nanoleaf_token,
|
||||
nanoleaf_min_interval_ms=update_data.nanoleaf_min_interval_ms,
|
||||
spi_speed_hz=update_data.spi_speed_hz,
|
||||
spi_led_type=update_data.spi_led_type,
|
||||
chroma_device_type=update_data.chroma_device_type,
|
||||
@@ -449,13 +644,12 @@ async def update_device(
|
||||
try:
|
||||
manager.update_device_info(
|
||||
device_id,
|
||||
device_url=update_data.url,
|
||||
led_count=update_data.led_count,
|
||||
device_url=normalized_url,
|
||||
led_count=normalized_led_count,
|
||||
baud_rate=update_data.baud_rate,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.debug("Processor manager device update skipped for %s: %s", device_id, e)
|
||||
pass
|
||||
|
||||
# Sync auto_shutdown and zone_mode in runtime state
|
||||
ds = manager.find_device_state(device_id)
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"""HTTP endpoint routes: CRUD + one-shot test."""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_http_endpoint_store,
|
||||
)
|
||||
from ledgrab.api.schemas.http_endpoints import (
|
||||
HTTPEndpointCreate,
|
||||
HTTPEndpointListResponse,
|
||||
HTTPEndpointResponse,
|
||||
HTTPEndpointUpdate,
|
||||
HTTPTestRequest,
|
||||
HTTPTestResponse,
|
||||
)
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
from ledgrab.storage.http_endpoint import HTTPEndpoint
|
||||
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.safe_source import safe_request_bounded, validate_polling_url
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _warn_if_plaintext_token(url: str, auth_token: str, *, action: str) -> None:
|
||||
"""Log a warning when an auth token would be sent over plaintext http://."""
|
||||
if auth_token and url.lower().startswith("http://"):
|
||||
logger.warning(
|
||||
"HTTP endpoint %s: auth_token will be sent over plaintext http:// to %s. "
|
||||
"Anyone on the network path can read it. Consider https:// if the "
|
||||
"target supports TLS.",
|
||||
action,
|
||||
url,
|
||||
)
|
||||
|
||||
|
||||
def _to_response(endpoint: HTTPEndpoint) -> HTTPEndpointResponse:
|
||||
return HTTPEndpointResponse(
|
||||
id=endpoint.id,
|
||||
name=endpoint.name,
|
||||
url=endpoint.url,
|
||||
method=endpoint.method,
|
||||
auth_token_set=bool(endpoint.auth_token),
|
||||
headers=dict(endpoint.headers),
|
||||
timeout_s=endpoint.timeout_s,
|
||||
description=endpoint.description,
|
||||
tags=endpoint.tags,
|
||||
icon=getattr(endpoint, "icon", "") or "",
|
||||
icon_color=getattr(endpoint, "icon_color", "") or "",
|
||||
created_at=endpoint.created_at,
|
||||
updated_at=endpoint.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/http/endpoints",
|
||||
response_model=HTTPEndpointListResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def list_http_endpoints(
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
endpoints = store.get_all_endpoints()
|
||||
return HTTPEndpointListResponse(
|
||||
endpoints=[_to_response(e) for e in endpoints],
|
||||
count=len(endpoints),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/http/endpoints",
|
||||
response_model=HTTPEndpointResponse,
|
||||
status_code=201,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def create_http_endpoint(
|
||||
data: HTTPEndpointCreate,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
validate_polling_url(data.url)
|
||||
_warn_if_plaintext_token(data.url, data.auth_token, action="create")
|
||||
try:
|
||||
endpoint = store.create_endpoint(
|
||||
name=data.name,
|
||||
url=data.url,
|
||||
method=data.method,
|
||||
auth_token=data.auth_token,
|
||||
headers=data.headers,
|
||||
timeout_s=data.timeout_s,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
fire_entity_event("http_endpoint", "created", endpoint.id)
|
||||
return _to_response(endpoint)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/http/endpoints/{endpoint_id}",
|
||||
response_model=HTTPEndpointResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def get_http_endpoint(
|
||||
endpoint_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
try:
|
||||
endpoint = store.get_endpoint(endpoint_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
return _to_response(endpoint)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/http/endpoints/{endpoint_id}",
|
||||
response_model=HTTPEndpointResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def update_http_endpoint(
|
||||
endpoint_id: str,
|
||||
data: HTTPEndpointUpdate,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
if data.url is not None:
|
||||
validate_polling_url(data.url)
|
||||
final_url = data.url
|
||||
final_token = data.auth_token
|
||||
if final_url is None or final_token is None:
|
||||
try:
|
||||
existing = store.get_endpoint(endpoint_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
if final_url is None:
|
||||
final_url = existing.url
|
||||
if final_token is None:
|
||||
final_token = existing.auth_token
|
||||
_warn_if_plaintext_token(final_url, final_token, action="update")
|
||||
try:
|
||||
endpoint = store.update_endpoint(
|
||||
endpoint_id,
|
||||
name=data.name,
|
||||
url=data.url,
|
||||
method=data.method,
|
||||
auth_token=data.auth_token,
|
||||
headers=data.headers,
|
||||
timeout_s=data.timeout_s,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
fire_entity_event("http_endpoint", "updated", endpoint.id)
|
||||
return _to_response(endpoint)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/api/v1/http/endpoints/{endpoint_id}",
|
||||
status_code=204,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def delete_http_endpoint(
|
||||
endpoint_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
try:
|
||||
store.delete_endpoint(endpoint_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
fire_entity_event("http_endpoint", "deleted", endpoint_id)
|
||||
|
||||
|
||||
async def _run_http_test(
|
||||
method: str,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
timeout_s: float,
|
||||
) -> HTTPTestResponse:
|
||||
"""Shared one-shot fetch + response shaping for both test endpoints."""
|
||||
try:
|
||||
status, body_bytes, error = await safe_request_bounded(
|
||||
method, url, headers=headers, timeout=timeout_s
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
return HTTPTestResponse(success=False, error=f"Unexpected error: {type(exc).__name__}")
|
||||
|
||||
if error and status == 0:
|
||||
return HTTPTestResponse(success=False, error=error)
|
||||
|
||||
try:
|
||||
body_text = body_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
body_text = body_bytes.decode("utf-8", errors="replace")
|
||||
try:
|
||||
body_json = json.loads(body_text) if body_text else None
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
body_json = None
|
||||
|
||||
preview = body_text[:500] if body_text else None
|
||||
is_success = 200 <= status < 300
|
||||
return HTTPTestResponse(
|
||||
success=is_success,
|
||||
status_code=status,
|
||||
body_preview=preview,
|
||||
body_json=body_json,
|
||||
error=None if is_success else f"HTTP {status}",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/http/endpoints/test",
|
||||
response_model=HTTPTestResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def test_http_endpoint(
|
||||
data: HTTPTestRequest,
|
||||
_auth: AuthRequired,
|
||||
):
|
||||
"""One-shot fetch to validate URL + auth before saving."""
|
||||
headers = dict(data.headers)
|
||||
if data.auth_token and not any(k.lower() == "authorization" for k in headers):
|
||||
headers["Authorization"] = f"Bearer {data.auth_token}"
|
||||
return await _run_http_test(data.method, data.url, headers, data.timeout_s)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/http/endpoints/{endpoint_id}/test",
|
||||
response_model=HTTPTestResponse,
|
||||
tags=["HTTP"],
|
||||
)
|
||||
async def test_saved_http_endpoint(
|
||||
endpoint_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: HTTPEndpointStore = Depends(get_http_endpoint_store),
|
||||
):
|
||||
"""Run the stored endpoint configuration (URL + auth + headers + timeout).
|
||||
|
||||
Useful for the "test" button on the endpoint card: avoids the user
|
||||
having to open the editor and re-enter the auth token (which is
|
||||
never returned to the client).
|
||||
"""
|
||||
try:
|
||||
endpoint = store.get_endpoint(endpoint_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found")
|
||||
return await _run_http_test(
|
||||
endpoint.method,
|
||||
endpoint.url,
|
||||
endpoint.build_request_headers(),
|
||||
endpoint.timeout_s,
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Output target routes: CRUD endpoints and batch state/metrics queries."""
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Depends
|
||||
|
||||
@@ -9,18 +9,27 @@ from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_device_store,
|
||||
get_mqtt_store,
|
||||
get_output_target_store,
|
||||
get_processor_manager,
|
||||
get_value_source_store,
|
||||
)
|
||||
from ledgrab.api.schemas.output_targets import (
|
||||
HALightMappingSchema,
|
||||
HALightOutputTargetCreate,
|
||||
HALightOutputTargetResponse,
|
||||
HALightOutputTargetUpdate,
|
||||
LedOutputTargetCreate,
|
||||
LedOutputTargetResponse,
|
||||
LedOutputTargetUpdate,
|
||||
OutputTargetCreate,
|
||||
OutputTargetListResponse,
|
||||
OutputTargetResponse,
|
||||
OutputTargetUpdate,
|
||||
Z2MLightMappingSchema,
|
||||
Z2MLightOutputTargetCreate,
|
||||
Z2MLightOutputTargetResponse,
|
||||
Z2MLightOutputTargetUpdate,
|
||||
)
|
||||
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||
from ledgrab.storage import DeviceStore
|
||||
@@ -30,6 +39,11 @@ from ledgrab.storage.ha_light_output_target import (
|
||||
HALightMapping,
|
||||
HALightOutputTarget,
|
||||
)
|
||||
from ledgrab.storage.z2m_light_output_target import (
|
||||
Z2MLightMapping,
|
||||
Z2MLightOutputTarget,
|
||||
)
|
||||
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
|
||||
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||
from ledgrab.utils import get_logger
|
||||
@@ -99,6 +113,42 @@ def _ha_light_target_to_response(
|
||||
)
|
||||
|
||||
|
||||
def _z2m_light_target_to_response(
|
||||
target: Z2MLightOutputTarget,
|
||||
) -> Z2MLightOutputTargetResponse:
|
||||
"""Convert a Z2MLightOutputTarget to Z2MLightOutputTargetResponse."""
|
||||
return Z2MLightOutputTargetResponse(
|
||||
id=target.id,
|
||||
name=target.name,
|
||||
mqtt_source_id=target.mqtt_source_id or "",
|
||||
source_kind=target.source_kind if target.source_kind in ("css", "color_vs") else "css",
|
||||
color_strip_source_id=target.color_strip_source_id or "",
|
||||
color_value_source_id=target.color_value_source_id or "",
|
||||
brightness=target.brightness.to_dict(),
|
||||
z2m_light_mappings=[
|
||||
Z2MLightMappingSchema(
|
||||
friendly_name=m.friendly_name,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=m.brightness_scale.to_dict(),
|
||||
)
|
||||
for m in target.light_mappings
|
||||
],
|
||||
base_topic=target.base_topic,
|
||||
update_rate=target.update_rate.to_dict(),
|
||||
transition=target.transition.to_dict(),
|
||||
color_tolerance=target.color_tolerance.to_dict(),
|
||||
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
|
||||
stop_action=target.stop_action if target.stop_action in ("none", "turn_off") else "none",
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
icon=getattr(target, "icon", "") or "",
|
||||
icon_color=getattr(target, "icon_color", "") or "",
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _validate_color_value_source(
|
||||
value_source_store: ValueSourceStore, color_value_source_id: str
|
||||
) -> None:
|
||||
@@ -125,27 +175,111 @@ def _validate_color_value_source(
|
||||
)
|
||||
|
||||
|
||||
def _target_to_response(target) -> OutputTargetResponse:
|
||||
"""Convert any OutputTarget to the appropriate typed response."""
|
||||
if isinstance(target, WledOutputTarget):
|
||||
return _led_target_to_response(target)
|
||||
elif isinstance(target, HALightOutputTarget):
|
||||
return _ha_light_target_to_response(target)
|
||||
else:
|
||||
# Fallback for unknown types — use LED response with defaults
|
||||
return LedOutputTargetResponse(
|
||||
id=target.id,
|
||||
name=target.name,
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
_TARGET_RESPONSE_BUILDERS: dict = {
|
||||
WledOutputTarget: _led_target_to_response,
|
||||
HALightOutputTarget: _ha_light_target_to_response,
|
||||
Z2MLightOutputTarget: _z2m_light_target_to_response,
|
||||
}
|
||||
|
||||
|
||||
def _assert_target_response_coverage() -> None:
|
||||
"""Verify the response registry covers every concrete OutputTarget subclass.
|
||||
|
||||
Runs at module import. Surfaces a missing builder eagerly instead of
|
||||
letting a request fall through to the previous silent fallback (which
|
||||
used to return a defaults-filled LedOutputTargetResponse and quietly
|
||||
misshape the payload for unknown target types).
|
||||
"""
|
||||
expected = {WledOutputTarget, HALightOutputTarget, Z2MLightOutputTarget}
|
||||
registered = set(_TARGET_RESPONSE_BUILDERS.keys())
|
||||
missing = expected - registered
|
||||
extra = registered - expected
|
||||
if missing or extra:
|
||||
problems = []
|
||||
if missing:
|
||||
problems.append(f"missing builders: {sorted(c.__name__ for c in missing)}")
|
||||
if extra:
|
||||
problems.append(f"unregistered classes: {sorted(c.__name__ for c in extra)}")
|
||||
raise RuntimeError(
|
||||
"_TARGET_RESPONSE_BUILDERS is out of sync with the OutputTarget "
|
||||
"subclass set: " + "; ".join(problems)
|
||||
)
|
||||
|
||||
|
||||
_assert_target_response_coverage()
|
||||
|
||||
|
||||
def _target_to_response(target) -> OutputTargetResponse:
|
||||
"""Convert any OutputTarget to the appropriate typed response.
|
||||
|
||||
Dispatches via :data:`_TARGET_RESPONSE_BUILDERS` keyed by concrete
|
||||
subclass. Raises ``RuntimeError`` for an unregistered subclass —
|
||||
coverage is asserted at import, so this should never fire in
|
||||
practice; if it does, the storage layer added a new OutputTarget
|
||||
subclass without a matching response builder here.
|
||||
"""
|
||||
builder = _TARGET_RESPONSE_BUILDERS.get(type(target))
|
||||
if builder is None:
|
||||
raise RuntimeError(
|
||||
f"No response builder registered for OutputTarget subclass " f"{type(target).__name__}"
|
||||
)
|
||||
return builder(target)
|
||||
|
||||
|
||||
# ===== CRUD ENDPOINTS =====
|
||||
|
||||
|
||||
def _build_ha_mappings(
|
||||
payload: list[HALightMappingSchema] | None,
|
||||
) -> list[HALightMapping] | None:
|
||||
if not payload:
|
||||
return None
|
||||
return [
|
||||
HALightMapping(
|
||||
entity_id=m.entity_id,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||
)
|
||||
for m in payload
|
||||
]
|
||||
|
||||
|
||||
def _build_z2m_mappings(
|
||||
payload: list[Z2MLightMappingSchema] | None,
|
||||
) -> list[Z2MLightMapping] | None:
|
||||
if not payload:
|
||||
return None
|
||||
return [
|
||||
Z2MLightMapping(
|
||||
friendly_name=m.friendly_name,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||
)
|
||||
for m in payload
|
||||
]
|
||||
|
||||
|
||||
def _validate_device_exists(device_store: DeviceStore, device_id: str) -> None:
|
||||
if not device_id:
|
||||
return
|
||||
try:
|
||||
device_store.get_device(device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
@@ -156,65 +290,69 @@ async def create_target(
|
||||
device_store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
value_source_store: ValueSourceStore = Depends(get_value_source_store),
|
||||
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
|
||||
):
|
||||
"""Create a new output target."""
|
||||
try:
|
||||
# Validate device exists if provided
|
||||
device_id = getattr(data, "device_id", "")
|
||||
if device_id:
|
||||
try:
|
||||
device_store.get_device(device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
|
||||
|
||||
# Validate color VS reference for HA-light targets in color_vs mode
|
||||
if (
|
||||
getattr(data, "target_type", "") == "ha_light"
|
||||
and getattr(data, "source_kind", "css") == "color_vs"
|
||||
):
|
||||
_validate_color_value_source(
|
||||
value_source_store, getattr(data, "color_value_source_id", "")
|
||||
)
|
||||
|
||||
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
|
||||
ha_mappings = (
|
||||
[
|
||||
HALightMapping(
|
||||
entity_id=m.entity_id,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||
match data:
|
||||
case LedOutputTargetCreate():
|
||||
_validate_device_exists(device_store, data.device_id)
|
||||
target = target_store.create_wled_target(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
device_id=data.device_id,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
brightness=data.brightness,
|
||||
fps=data.fps,
|
||||
keepalive_interval=data.keepalive_interval,
|
||||
state_check_interval=data.state_check_interval,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
adaptive_fps=data.adaptive_fps,
|
||||
protocol=data.protocol,
|
||||
)
|
||||
for m in ha_light_mappings_raw
|
||||
]
|
||||
if ha_light_mappings_raw
|
||||
else None
|
||||
)
|
||||
|
||||
# Create in store
|
||||
target = target_store.create_target(
|
||||
name=data.name,
|
||||
target_type=data.target_type,
|
||||
device_id=device_id,
|
||||
color_strip_source_id=getattr(data, "color_strip_source_id", ""),
|
||||
brightness=getattr(data, "brightness", 1.0),
|
||||
fps=getattr(data, "fps", 30),
|
||||
keepalive_interval=getattr(data, "keepalive_interval", 1.0),
|
||||
state_check_interval=getattr(data, "state_check_interval", 30),
|
||||
min_brightness_threshold=getattr(data, "min_brightness_threshold", 0),
|
||||
adaptive_fps=getattr(data, "adaptive_fps", False),
|
||||
protocol=getattr(data, "protocol", "ddp"),
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
ha_source_id=getattr(data, "ha_source_id", ""),
|
||||
source_kind=getattr(data, "source_kind", "css"),
|
||||
color_value_source_id=getattr(data, "color_value_source_id", ""),
|
||||
ha_light_mappings=ha_mappings,
|
||||
update_rate=getattr(data, "update_rate", 2.0),
|
||||
transition=getattr(data, "transition", 0.5),
|
||||
color_tolerance=getattr(data, "color_tolerance", 5),
|
||||
stop_action=getattr(data, "stop_action", "none"),
|
||||
)
|
||||
case HALightOutputTargetCreate():
|
||||
if data.source_kind == "color_vs":
|
||||
_validate_color_value_source(value_source_store, data.color_value_source_id)
|
||||
target = target_store.create_ha_light_target(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
ha_source_id=data.ha_source_id,
|
||||
source_kind=data.source_kind,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
color_value_source_id=data.color_value_source_id,
|
||||
brightness=data.brightness,
|
||||
ha_light_mappings=_build_ha_mappings(data.ha_light_mappings),
|
||||
update_rate=data.update_rate,
|
||||
transition=data.transition,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
color_tolerance=data.color_tolerance,
|
||||
stop_action=data.stop_action,
|
||||
)
|
||||
case Z2MLightOutputTargetCreate():
|
||||
if data.source_kind == "color_vs":
|
||||
_validate_color_value_source(value_source_store, data.color_value_source_id)
|
||||
_validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
|
||||
target = target_store.create_z2m_light_target(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
mqtt_source_id=data.mqtt_source_id,
|
||||
source_kind=data.source_kind,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
color_value_source_id=data.color_value_source_id,
|
||||
brightness=data.brightness,
|
||||
z2m_light_mappings=_build_z2m_mappings(data.z2m_light_mappings),
|
||||
base_topic=data.base_topic,
|
||||
update_rate=data.update_rate,
|
||||
transition=data.transition,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
color_tolerance=data.color_tolerance,
|
||||
stop_action=data.stop_action,
|
||||
)
|
||||
case _: # pragma: no cover — Pydantic discriminator already ensures one of the three
|
||||
raise HTTPException(status_code=400, detail="Unknown target_type")
|
||||
|
||||
# Register in processor manager
|
||||
try:
|
||||
@@ -282,6 +420,18 @@ async def get_target(
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
def _resolve_effective_color_vs_id(
|
||||
target_store: OutputTargetStore, target_id: str, payload_id: Optional[str]
|
||||
) -> str:
|
||||
if payload_id is not None:
|
||||
return payload_id
|
||||
try:
|
||||
existing = target_store.get_target(target_id)
|
||||
except ValueError:
|
||||
return ""
|
||||
return getattr(existing, "color_value_source_id", "") or ""
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]
|
||||
)
|
||||
@@ -293,116 +443,160 @@ async def update_target(
|
||||
device_store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
value_source_store: ValueSourceStore = Depends(get_value_source_store),
|
||||
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
|
||||
):
|
||||
"""Update a output target."""
|
||||
try:
|
||||
# Validate device exists if changing
|
||||
device_id = getattr(data, "device_id", None)
|
||||
if device_id is not None and device_id:
|
||||
try:
|
||||
device_store.get_device(device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
|
||||
css_changed = False
|
||||
brightness_changed = False
|
||||
settings_changed = False
|
||||
device_changed = False
|
||||
|
||||
# Validate color VS reference for HA-light targets switching into / staying in color_vs
|
||||
if getattr(data, "target_type", "") == "ha_light":
|
||||
new_kind = getattr(data, "source_kind", None)
|
||||
new_color_vs = getattr(data, "color_value_source_id", None)
|
||||
if new_kind == "color_vs" or (new_kind is None and new_color_vs):
|
||||
# Determine effective id: payload id if provided, else existing target's id
|
||||
effective_id = new_color_vs
|
||||
if effective_id is None:
|
||||
try:
|
||||
existing = target_store.get_target(target_id)
|
||||
effective_id = getattr(existing, "color_value_source_id", "")
|
||||
except ValueError:
|
||||
effective_id = ""
|
||||
_validate_color_value_source(value_source_store, effective_id or "")
|
||||
|
||||
# Build HA light mappings if provided
|
||||
ha_light_mappings_raw = getattr(data, "ha_light_mappings", None)
|
||||
ha_mappings = None
|
||||
if ha_light_mappings_raw is not None:
|
||||
ha_mappings = [
|
||||
HALightMapping(
|
||||
entity_id=m.entity_id,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=BindableFloat.from_raw(m.brightness_scale, default=1.0),
|
||||
match data:
|
||||
case LedOutputTargetUpdate():
|
||||
if data.device_id:
|
||||
_validate_device_exists(device_store, data.device_id)
|
||||
target = target_store.update_wled_target(
|
||||
target_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
device_id=data.device_id,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
brightness=data.brightness,
|
||||
fps=data.fps,
|
||||
keepalive_interval=data.keepalive_interval,
|
||||
state_check_interval=data.state_check_interval,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
adaptive_fps=data.adaptive_fps,
|
||||
protocol=data.protocol,
|
||||
)
|
||||
for m in ha_light_mappings_raw
|
||||
]
|
||||
|
||||
# Update in store
|
||||
target = target_store.update_target(
|
||||
target_id=target_id,
|
||||
name=data.name,
|
||||
device_id=device_id,
|
||||
color_strip_source_id=getattr(data, "color_strip_source_id", None),
|
||||
brightness=getattr(data, "brightness", None),
|
||||
fps=getattr(data, "fps", None),
|
||||
keepalive_interval=getattr(data, "keepalive_interval", None),
|
||||
state_check_interval=getattr(data, "state_check_interval", None),
|
||||
min_brightness_threshold=getattr(data, "min_brightness_threshold", None),
|
||||
adaptive_fps=getattr(data, "adaptive_fps", None),
|
||||
protocol=getattr(data, "protocol", None),
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
ha_source_id=getattr(data, "ha_source_id", None),
|
||||
source_kind=getattr(data, "source_kind", None),
|
||||
color_value_source_id=getattr(data, "color_value_source_id", None),
|
||||
ha_light_mappings=ha_mappings,
|
||||
update_rate=getattr(data, "update_rate", None),
|
||||
transition=getattr(data, "transition", None),
|
||||
color_tolerance=getattr(data, "color_tolerance", None),
|
||||
stop_action=getattr(data, "stop_action", None),
|
||||
)
|
||||
css_changed = data.color_strip_source_id is not None
|
||||
brightness_changed = data.brightness is not None
|
||||
settings_changed = any(
|
||||
v is not None
|
||||
for v in (
|
||||
data.fps,
|
||||
data.keepalive_interval,
|
||||
data.state_check_interval,
|
||||
data.min_brightness_threshold,
|
||||
data.adaptive_fps,
|
||||
data.brightness,
|
||||
)
|
||||
)
|
||||
device_changed = data.device_id is not None
|
||||
case HALightOutputTargetUpdate():
|
||||
# Validate color VS when switching into / staying in color_vs mode
|
||||
if data.source_kind == "color_vs" or (
|
||||
data.source_kind is None and data.color_value_source_id
|
||||
):
|
||||
effective_id = _resolve_effective_color_vs_id(
|
||||
target_store, target_id, data.color_value_source_id
|
||||
)
|
||||
_validate_color_value_source(value_source_store, effective_id)
|
||||
target = target_store.update_ha_light_target(
|
||||
target_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
ha_source_id=data.ha_source_id,
|
||||
source_kind=data.source_kind,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
color_value_source_id=data.color_value_source_id,
|
||||
brightness=data.brightness,
|
||||
ha_light_mappings=_build_ha_mappings(data.ha_light_mappings),
|
||||
update_rate=data.update_rate,
|
||||
transition=data.transition,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
color_tolerance=data.color_tolerance,
|
||||
stop_action=data.stop_action,
|
||||
)
|
||||
css_changed = data.color_strip_source_id is not None
|
||||
brightness_changed = data.brightness is not None
|
||||
settings_changed = any(
|
||||
v is not None
|
||||
for v in (
|
||||
data.source_kind,
|
||||
data.color_value_source_id,
|
||||
data.brightness,
|
||||
data.update_rate,
|
||||
data.transition,
|
||||
data.min_brightness_threshold,
|
||||
data.color_tolerance,
|
||||
data.ha_light_mappings,
|
||||
data.stop_action,
|
||||
)
|
||||
)
|
||||
case Z2MLightOutputTargetUpdate():
|
||||
if data.source_kind == "color_vs" or (
|
||||
data.source_kind is None and data.color_value_source_id
|
||||
):
|
||||
effective_id = _resolve_effective_color_vs_id(
|
||||
target_store, target_id, data.color_value_source_id
|
||||
)
|
||||
_validate_color_value_source(value_source_store, effective_id)
|
||||
if data.mqtt_source_id:
|
||||
_validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
|
||||
target = target_store.update_z2m_light_target(
|
||||
target_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
icon=data.icon,
|
||||
icon_color=data.icon_color,
|
||||
mqtt_source_id=data.mqtt_source_id,
|
||||
source_kind=data.source_kind,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
color_value_source_id=data.color_value_source_id,
|
||||
brightness=data.brightness,
|
||||
z2m_light_mappings=_build_z2m_mappings(data.z2m_light_mappings),
|
||||
base_topic=data.base_topic,
|
||||
update_rate=data.update_rate,
|
||||
transition=data.transition,
|
||||
min_brightness_threshold=data.min_brightness_threshold,
|
||||
color_tolerance=data.color_tolerance,
|
||||
stop_action=data.stop_action,
|
||||
)
|
||||
css_changed = data.color_strip_source_id is not None
|
||||
brightness_changed = data.brightness is not None
|
||||
settings_changed = any(
|
||||
v is not None
|
||||
for v in (
|
||||
data.source_kind,
|
||||
data.color_value_source_id,
|
||||
data.mqtt_source_id,
|
||||
data.brightness,
|
||||
data.base_topic,
|
||||
data.update_rate,
|
||||
data.transition,
|
||||
data.min_brightness_threshold,
|
||||
data.color_tolerance,
|
||||
data.z2m_light_mappings,
|
||||
data.stop_action,
|
||||
)
|
||||
)
|
||||
case _: # pragma: no cover — Pydantic discriminator already ensures one of the three
|
||||
raise HTTPException(status_code=400, detail="Unknown target_type")
|
||||
|
||||
# Sync processor manager (run in thread — css release/acquire can block)
|
||||
color_strip_source_id = getattr(data, "color_strip_source_id", None)
|
||||
fps = getattr(data, "fps", None)
|
||||
keepalive_interval = getattr(data, "keepalive_interval", None)
|
||||
state_check_interval = getattr(data, "state_check_interval", None)
|
||||
min_brightness_threshold = getattr(data, "min_brightness_threshold", None)
|
||||
adaptive_fps = getattr(data, "adaptive_fps", None)
|
||||
update_rate = getattr(data, "update_rate", None)
|
||||
transition = getattr(data, "transition", None)
|
||||
color_tolerance = getattr(data, "color_tolerance", None)
|
||||
brightness = getattr(data, "brightness", None)
|
||||
stop_action = getattr(data, "stop_action", None)
|
||||
source_kind = getattr(data, "source_kind", None)
|
||||
color_value_source_id = getattr(data, "color_value_source_id", None)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
target.sync_with_manager,
|
||||
manager,
|
||||
settings_changed=(
|
||||
fps is not None
|
||||
or keepalive_interval is not None
|
||||
or state_check_interval is not None
|
||||
or min_brightness_threshold is not None
|
||||
or adaptive_fps is not None
|
||||
or update_rate is not None
|
||||
or transition is not None
|
||||
or color_tolerance is not None
|
||||
or ha_light_mappings_raw is not None
|
||||
or brightness is not None
|
||||
or stop_action is not None
|
||||
or source_kind is not None
|
||||
or color_value_source_id is not None
|
||||
),
|
||||
css_changed=color_strip_source_id is not None,
|
||||
brightness_changed=brightness is not None,
|
||||
settings_changed=settings_changed,
|
||||
css_changed=css_changed,
|
||||
brightness_changed=brightness_changed,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.debug("Processor config update skipped for target %s: %s", target_id, e)
|
||||
pass
|
||||
|
||||
# Device change requires async stop -> swap -> start cycle
|
||||
if device_id is not None:
|
||||
# LED-only: device change requires async stop -> swap -> start cycle
|
||||
if device_changed and isinstance(target, WledOutputTarget):
|
||||
try:
|
||||
await manager.update_target_device(target_id, target.device_id)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -335,6 +335,35 @@ async def get_overlay_status(
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
# ===== HA LIGHT — MANUAL TURN OFF =====
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/ha-light/turn-off", tags=["Processing"])
|
||||
async def turn_off_ha_light_target(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Turn off all HA light entities mapped by the target.
|
||||
|
||||
Works regardless of whether the target's processor is running. Useful
|
||||
when ``stop_action`` is ``"none"`` and lights were left on after a stop.
|
||||
"""
|
||||
try:
|
||||
# Verify target exists
|
||||
target_store.get_target(target_id)
|
||||
count = await manager.turn_off_ha_light_target(target_id)
|
||||
return {"status": "ok", "target_id": target_id, "entities": count}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to turn off HA lights: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# ===== HA LIGHT COLOR PREVIEW WEBSOCKET =====
|
||||
|
||||
|
||||
@@ -377,6 +406,75 @@ async def ha_light_colors_ws(
|
||||
manager.remove_ha_light_ws_client(target_id, websocket)
|
||||
|
||||
|
||||
# ===== Z2M LIGHT — MANUAL TURN OFF =====
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/z2m-light/turn-off", tags=["Processing"])
|
||||
async def turn_off_z2m_light_target(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Publish OFF to all Z2M bulbs mapped by the target.
|
||||
|
||||
Works regardless of whether the target's processor is running. Useful
|
||||
when ``stop_action`` is ``"none"`` and bulbs were left on after a stop.
|
||||
"""
|
||||
try:
|
||||
target_store.get_target(target_id)
|
||||
count = await manager.turn_off_z2m_light_target(target_id)
|
||||
return {"status": "ok", "target_id": target_id, "entities": count}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to turn off Z2M lights: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# ===== Z2M LIGHT COLOR PREVIEW WEBSOCKET =====
|
||||
|
||||
|
||||
@router.websocket("/api/v1/output-targets/{target_id}/z2m-light/ws")
|
||||
async def z2m_light_colors_ws(
|
||||
websocket: WebSocket,
|
||||
target_id: str,
|
||||
):
|
||||
"""WebSocket for live Z2M bulb colour preview.
|
||||
|
||||
Streams: {"type":"colors_update","colors":{friendly_name:{r,g,b,hex},...}}
|
||||
at the target's update_rate. Auth via first-message handshake.
|
||||
"""
|
||||
from ledgrab.api.auth import accept_and_authenticate_ws
|
||||
|
||||
if await accept_and_authenticate_ws(websocket) is None:
|
||||
return
|
||||
|
||||
manager: ProcessorManager = get_processor_manager()
|
||||
|
||||
try:
|
||||
proc = manager._processors.get(target_id)
|
||||
if not proc or not proc.is_running:
|
||||
await websocket.close(code=4003, reason="Target not running")
|
||||
return
|
||||
except Exception as e:
|
||||
await websocket.close(code=4004, reason=str(e))
|
||||
return
|
||||
|
||||
try:
|
||||
manager.add_z2m_light_ws_client(target_id, websocket)
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except (RuntimeError, ConnectionError) as e:
|
||||
logger.debug("ws closed in z2m-light client: %s", e)
|
||||
finally:
|
||||
manager.remove_z2m_light_ws_client(target_id, websocket)
|
||||
|
||||
|
||||
# ===== LED PREVIEW WEBSOCKET =====
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from ledgrab.api.dependencies import (
|
||||
get_device_store,
|
||||
get_ha_manager,
|
||||
get_ha_store,
|
||||
get_mqtt_manager,
|
||||
get_output_target_store,
|
||||
get_picture_source_store,
|
||||
get_pp_template_store,
|
||||
@@ -380,22 +381,20 @@ async def get_integrations_status(
|
||||
_: AuthRequired,
|
||||
ha_store=Depends(get_ha_store),
|
||||
ha_manager=Depends(get_ha_manager),
|
||||
mqtt_manager=Depends(get_mqtt_manager),
|
||||
):
|
||||
"""Return connection status for external integrations (MQTT, Home Assistant).
|
||||
|
||||
Used by the dashboard to show connectivity indicators.
|
||||
Used by the dashboard to show connectivity indicators. MQTT is reported
|
||||
per-source since the multi-broker refactor — no more global "MQTT
|
||||
enabled" flag.
|
||||
"""
|
||||
from ledgrab.core.devices.mqtt_client import get_mqtt_service
|
||||
|
||||
# MQTT status
|
||||
mqtt_service = get_mqtt_service()
|
||||
mqtt_config = get_config().mqtt
|
||||
# MQTT status — one entry per configured source
|
||||
mqtt_items = mqtt_manager.get_all_sources_status()
|
||||
mqtt_status = {
|
||||
"enabled": mqtt_config.enabled,
|
||||
"connected": mqtt_service.is_connected if mqtt_service else False,
|
||||
"broker": (
|
||||
f"{mqtt_config.broker_host}:{mqtt_config.broker_port}" if mqtt_config.enabled else None
|
||||
),
|
||||
"sources": mqtt_items,
|
||||
"total": len(mqtt_items),
|
||||
"connected": sum(1 for s in mqtt_items if s.get("connected")),
|
||||
}
|
||||
|
||||
# Home Assistant status
|
||||
|
||||
@@ -23,6 +23,7 @@ from ledgrab.api.schemas.value_sources import (
|
||||
DaylightValueSourceResponse,
|
||||
GradientMapValueSourceResponse,
|
||||
HAEntityValueSourceResponse,
|
||||
HTTPValueSourceResponse,
|
||||
StaticColorValueSourceResponse,
|
||||
StaticValueSourceResponse,
|
||||
SystemMetricsValueSourceResponse,
|
||||
@@ -41,6 +42,7 @@ from ledgrab.storage.value_source import (
|
||||
DaylightValueSource,
|
||||
GradientMapValueSource,
|
||||
HAEntityValueSource,
|
||||
HTTPValueSource,
|
||||
StaticColorValueSource,
|
||||
StaticValueSource,
|
||||
SystemMetricsValueSource,
|
||||
@@ -213,6 +215,22 @@ _RESPONSE_MAP = {
|
||||
poll_interval=s.poll_interval,
|
||||
smoothing=s.smoothing,
|
||||
),
|
||||
HTTPValueSource: lambda s: HTTPValueSourceResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
icon=getattr(s, "icon", "") or "",
|
||||
icon_color=getattr(s, "icon_color", "") or "",
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
http_endpoint_id=s.http_endpoint_id,
|
||||
json_path=s.json_path,
|
||||
interval_s=s.interval_s,
|
||||
min_value=s.min_value,
|
||||
max_value=s.max_value,
|
||||
smoothing=s.smoothing,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ _RATE_WINDOW = 60.0 # seconds
|
||||
_rate_hits: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
|
||||
_RATE_HITS_HARD_CAP = 1024
|
||||
|
||||
|
||||
def _check_rate_limit(client_ip: str) -> None:
|
||||
"""Raise 429 if *client_ip* exceeded the webhook rate limit."""
|
||||
now = time.time()
|
||||
@@ -44,11 +47,21 @@ def _check_rate_limit(client_ip: str) -> None:
|
||||
)
|
||||
_rate_hits[client_ip].append(now)
|
||||
|
||||
# Periodic cleanup: remove IPs with no recent hits to prevent unbounded growth
|
||||
# Periodic cleanup: remove IPs with no recent hits to prevent unbounded growth.
|
||||
if len(_rate_hits) > 100:
|
||||
stale = [ip for ip, ts in _rate_hits.items() if not ts or ts[-1] < window_start]
|
||||
for ip in stale:
|
||||
del _rate_hits[ip]
|
||||
# Hard cap as a final defence against an attacker spraying many distinct
|
||||
# X-Forwarded-For values to drive memory growth past the soft cleanup
|
||||
# threshold. Drop the oldest-touched IPs (by their latest timestamp).
|
||||
if len(_rate_hits) > _RATE_HITS_HARD_CAP:
|
||||
ordered = sorted(
|
||||
_rate_hits.items(),
|
||||
key=lambda kv: kv[1][-1] if kv[1] else 0.0,
|
||||
)
|
||||
for ip, _ in ordered[: len(ordered) - _RATE_HITS_HARD_CAP]:
|
||||
_rate_hits.pop(ip, None)
|
||||
|
||||
|
||||
class WebhookPayload(BaseModel):
|
||||
|
||||
@@ -46,6 +46,24 @@ class RuleSchema(BaseModel):
|
||||
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(
|
||||
None,
|
||||
description=(
|
||||
"Value source ID (for http_poll rule). The referenced "
|
||||
"ValueSource must be of source_type='http'."
|
||||
),
|
||||
)
|
||||
operator: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Comparison operator for http_poll rule: "
|
||||
"'equals', 'not_equals', 'contains', 'regex', 'gt', 'lt', 'exists'."
|
||||
),
|
||||
)
|
||||
value: Optional[str] = Field(
|
||||
None, description="Expected value (for http_poll rule; ignored for 'exists')"
|
||||
)
|
||||
|
||||
|
||||
# Backward-compatible alias
|
||||
|
||||
@@ -122,9 +122,9 @@ class PictureAdvancedCSSResponse(_CSSResponseBase):
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||
|
||||
|
||||
class StaticCSSResponse(_CSSResponseBase):
|
||||
source_type: Literal["static"] = "static"
|
||||
color: Any = Field(description="Static RGB color")
|
||||
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")
|
||||
|
||||
|
||||
@@ -240,11 +240,18 @@ class MathWaveCSSResponse(_CSSResponseBase):
|
||||
gradient_id: Optional[str] = Field(None, description="Gradient entity ID for color mapping")
|
||||
|
||||
|
||||
class GameEventCSSResponse(_CSSResponseBase):
|
||||
source_type: Literal["game_event"] = "game_event"
|
||||
game_integration_id: str = Field(description="Game integration entity ID")
|
||||
idle_color: Any = Field(description="Idle RGB color (bindable)")
|
||||
event_mappings: List[dict] = Field(default_factory=list, description="Event-to-effect mappings")
|
||||
|
||||
|
||||
ColorStripSourceResponse = Annotated[
|
||||
Union[
|
||||
Annotated[PictureCSSResponse, Tag("picture")],
|
||||
Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")],
|
||||
Annotated[StaticCSSResponse, Tag("static")],
|
||||
Annotated[SingleColorCSSResponse, Tag("single_color")],
|
||||
Annotated[GradientCSSResponse, Tag("gradient")],
|
||||
Annotated[EffectCSSResponse, Tag("effect")],
|
||||
Annotated[CompositeCSSResponse, Tag("composite")],
|
||||
@@ -258,6 +265,7 @@ ColorStripSourceResponse = Annotated[
|
||||
Annotated[WeatherCSSResponse, Tag("weather")],
|
||||
Annotated[KeyColorsCSSResponse, Tag("key_colors")],
|
||||
Annotated[MathWaveCSSResponse, Tag("math_wave")],
|
||||
Annotated[GameEventCSSResponse, Tag("game_event")],
|
||||
],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
@@ -303,9 +311,9 @@ class PictureAdvancedCSSCreate(_CSSCreateBase):
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||
|
||||
|
||||
class StaticCSSCreate(_CSSCreateBase):
|
||||
source_type: Literal["static"] = "static"
|
||||
color: Any = Field(default=None, description="Static RGB color [R,G,B]")
|
||||
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")
|
||||
|
||||
|
||||
@@ -434,11 +442,18 @@ class MathWaveCSSCreate(_CSSCreateBase):
|
||||
gradient_id: Optional[str] = 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")
|
||||
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")
|
||||
|
||||
|
||||
ColorStripSourceCreate = Annotated[
|
||||
Union[
|
||||
Annotated[PictureCSSCreate, Tag("picture")],
|
||||
Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")],
|
||||
Annotated[StaticCSSCreate, Tag("static")],
|
||||
Annotated[SingleColorCSSCreate, Tag("single_color")],
|
||||
Annotated[GradientCSSCreate, Tag("gradient")],
|
||||
Annotated[EffectCSSCreate, Tag("effect")],
|
||||
Annotated[CompositeCSSCreate, Tag("composite")],
|
||||
@@ -452,6 +467,7 @@ ColorStripSourceCreate = Annotated[
|
||||
Annotated[WeatherCSSCreate, Tag("weather")],
|
||||
Annotated[KeyColorsCSSCreate, Tag("key_colors")],
|
||||
Annotated[MathWaveCSSCreate, Tag("math_wave")],
|
||||
Annotated[GameEventCSSCreate, Tag("game_event")],
|
||||
],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
@@ -497,9 +513,9 @@ class PictureAdvancedCSSUpdate(_CSSUpdateBase):
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||
|
||||
|
||||
class StaticCSSUpdate(_CSSUpdateBase):
|
||||
source_type: Literal["static"] = "static"
|
||||
color: Any = Field(default=None, description="Static RGB color [R,G,B]")
|
||||
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")
|
||||
|
||||
|
||||
@@ -626,11 +642,18 @@ class MathWaveCSSUpdate(_CSSUpdateBase):
|
||||
gradient_id: Optional[str] = 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")
|
||||
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")
|
||||
|
||||
|
||||
ColorStripSourceUpdate = Annotated[
|
||||
Union[
|
||||
Annotated[PictureCSSUpdate, Tag("picture")],
|
||||
Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")],
|
||||
Annotated[StaticCSSUpdate, Tag("static")],
|
||||
Annotated[SingleColorCSSUpdate, Tag("single_color")],
|
||||
Annotated[GradientCSSUpdate, Tag("gradient")],
|
||||
Annotated[EffectCSSUpdate, Tag("effect")],
|
||||
Annotated[CompositeCSSUpdate, Tag("composite")],
|
||||
@@ -644,6 +667,7 @@ ColorStripSourceUpdate = Annotated[
|
||||
Annotated[WeatherCSSUpdate, Tag("weather")],
|
||||
Annotated[KeyColorsCSSUpdate, Tag("key_colors")],
|
||||
Annotated[MathWaveCSSUpdate, Tag("math_wave")],
|
||||
Annotated[GameEventCSSUpdate, Tag("game_event")],
|
||||
],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
|
||||
@@ -37,6 +37,19 @@ class DeviceCreate(BaseModel):
|
||||
dmx_start_channel: Optional[int] = Field(
|
||||
None, ge=1, le=512, description="DMX start channel (1-512)"
|
||||
)
|
||||
# DDP fields
|
||||
ddp_port: Optional[int] = 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 (default 1 = display)"
|
||||
)
|
||||
ddp_color_order: Optional[int] = 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(
|
||||
None, description="ESP-NOW peer MAC address (e.g. AA:BB:CC:DD:EE:FF)"
|
||||
@@ -50,6 +63,53 @@ class DeviceCreate(BaseModel):
|
||||
hue_entertainment_group_id: Optional[str] = Field(
|
||||
None, description="Hue entertainment group/zone ID"
|
||||
)
|
||||
# Yeelight fields
|
||||
yeelight_min_interval_ms: Optional[int] = 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(
|
||||
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(
|
||||
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(
|
||||
None,
|
||||
ge=0,
|
||||
le=10000,
|
||||
description="Govee client-side rate limit between commands in ms (default 50)",
|
||||
)
|
||||
# OPC fields
|
||||
opc_channel: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
le=255,
|
||||
description="OPC channel (0 = broadcast to all channels on the server)",
|
||||
)
|
||||
# Nanoleaf fields
|
||||
nanoleaf_token: Optional[str] = Field(
|
||||
None,
|
||||
max_length=512,
|
||||
description="Nanoleaf auth token returned by the pairing handshake",
|
||||
)
|
||||
nanoleaf_min_interval_ms: Optional[int] = 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(
|
||||
None, ge=100000, le=4000000, description="SPI clock speed in Hz"
|
||||
@@ -126,6 +186,11 @@ class DeviceUpdate(BaseModel):
|
||||
dmx_start_channel: Optional[int] = Field(
|
||||
None, ge=1, le=512, description="DMX start channel (1-512)"
|
||||
)
|
||||
ddp_port: Optional[int] = 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")
|
||||
@@ -133,6 +198,25 @@ class DeviceUpdate(BaseModel):
|
||||
hue_entertainment_group_id: Optional[str] = Field(
|
||||
None, description="Hue entertainment group ID"
|
||||
)
|
||||
yeelight_min_interval_ms: Optional[int] = Field(
|
||||
None, ge=0, le=10000, description="Yeelight client-side rate limit in ms"
|
||||
)
|
||||
wiz_min_interval_ms: Optional[int] = Field(
|
||||
None, ge=0, le=10000, description="WiZ client-side rate limit in ms"
|
||||
)
|
||||
lifx_min_interval_ms: Optional[int] = Field(
|
||||
None, ge=0, le=10000, description="LIFX client-side rate limit in ms"
|
||||
)
|
||||
govee_min_interval_ms: Optional[int] = 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(
|
||||
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")
|
||||
@@ -164,6 +248,32 @@ class DeviceUpdate(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class PairDeviceRequest(BaseModel):
|
||||
"""Initiate a pairing handshake with a device before creating it.
|
||||
|
||||
The caller is expected to have just performed the device's physical
|
||||
pairing action (e.g. holding the power button on a Nanoleaf for 5 s,
|
||||
pressing the Hue bridge link button). The response carries any
|
||||
provider-specific fields the frontend must include in the subsequent
|
||||
``POST /api/v1/devices`` payload — typically an auth token.
|
||||
"""
|
||||
|
||||
device_type: str = Field(description="Device type identifier (e.g. 'nanoleaf')")
|
||||
url: str = Field(description="Device URL (e.g. 'nanoleaf://192.168.1.50')")
|
||||
|
||||
|
||||
class PairDeviceResponse(BaseModel):
|
||||
"""Successful pairing result. ``fields`` is merged into the create payload."""
|
||||
|
||||
fields: Dict[str, object] = Field(
|
||||
default_factory=dict,
|
||||
description=(
|
||||
"Provider-specific fields to include in the subsequent device-create "
|
||||
"request (e.g. {'nanoleaf_token': 'abc...'})."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CalibrationLineSchema(BaseModel):
|
||||
"""One LED line in advanced calibration."""
|
||||
|
||||
@@ -294,11 +404,38 @@ class DeviceResponse(BaseModel):
|
||||
dmx_protocol: str = Field(default="artnet", description="DMX protocol: artnet or sacn")
|
||||
dmx_start_universe: int = Field(default=0, description="DMX start universe")
|
||||
dmx_start_channel: int = Field(default=1, description="DMX start channel (1-512)")
|
||||
ddp_port: int = Field(default=0, description="DDP UDP port (0 = protocol default 4048)")
|
||||
ddp_destination_id: int = Field(default=1, description="DDP destination ID")
|
||||
ddp_color_order: int = Field(default=1, description="DDP color order code (1 = RGB)")
|
||||
espnow_peer_mac: str = Field(default="", description="ESP-NOW peer MAC address")
|
||||
espnow_channel: int = Field(default=1, description="ESP-NOW WiFi channel")
|
||||
hue_username: str = Field(default="", description="Hue bridge username")
|
||||
hue_client_key: str = Field(default="", description="Hue entertainment client key")
|
||||
hue_paired: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Whether the Hue bridge has been paired (i.e. a username/client_key "
|
||||
"is on file). The actual credentials are intentionally not exposed "
|
||||
"in the response -- to re-pair, delete and re-add the device."
|
||||
),
|
||||
)
|
||||
hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID")
|
||||
yeelight_min_interval_ms: int = Field(
|
||||
default=500, description="Yeelight client-side rate limit in ms"
|
||||
)
|
||||
wiz_min_interval_ms: int = Field(default=50, description="WiZ client-side rate limit in ms")
|
||||
lifx_min_interval_ms: int = Field(default=50, description="LIFX client-side rate limit in ms")
|
||||
govee_min_interval_ms: int = Field(default=50, description="Govee client-side rate limit in ms")
|
||||
opc_channel: int = Field(default=0, description="OPC channel (0 = broadcast to all)")
|
||||
nanoleaf_paired: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Whether the Nanoleaf auth token has been issued by the pairing "
|
||||
"handshake. The token itself is intentionally not exposed in the "
|
||||
"response -- to re-pair, delete and re-add the device."
|
||||
),
|
||||
)
|
||||
nanoleaf_min_interval_ms: int = Field(
|
||||
default=100, description="Nanoleaf client-side rate limit in ms"
|
||||
)
|
||||
spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
|
||||
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
|
||||
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""HTTP endpoint schemas (CRUD + one-shot test)."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
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]")
|
||||
|
||||
|
||||
def _validate_headers(value: Dict[str, str]) -> Dict[str, str]:
|
||||
"""Reject header names/values that could enable CRLF injection."""
|
||||
if value is None:
|
||||
return {}
|
||||
cleaned: Dict[str, str] = {}
|
||||
for name, raw in value.items():
|
||||
if not isinstance(name, str) or not isinstance(raw, str):
|
||||
raise ValueError(f"Header name/value must be strings: {name!r}")
|
||||
if not _HEADER_NAME_RE.match(name):
|
||||
raise ValueError(f"Invalid HTTP header name: {name!r}")
|
||||
if _HEADER_CONTROL_CHARS_RE.search(raw):
|
||||
raise ValueError(
|
||||
f"Invalid HTTP header value for {name!r} (contains control characters)"
|
||||
)
|
||||
cleaned[name] = raw
|
||||
return cleaned
|
||||
|
||||
|
||||
def _validate_url(value: str) -> str:
|
||||
"""Reject URLs that embed ``user:pass@`` so credentials can't leak
|
||||
into server logs (e.g. via the plaintext-token warning helper).
|
||||
|
||||
Schemes + IP-block checks are enforced later by
|
||||
:func:`ledgrab.utils.safe_source.validate_polling_url`.
|
||||
"""
|
||||
if not value:
|
||||
return value
|
||||
parsed = urlparse(value)
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise ValueError(
|
||||
"URL must not embed credentials (user:pass@). "
|
||||
"Use the auth_token field or a custom Authorization header instead."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class HTTPEndpointCreate(BaseModel):
|
||||
"""Request to create an HTTP endpoint."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
url: str = Field(min_length=1, description="http or https URL")
|
||||
method: Literal["GET", "HEAD"] = "GET"
|
||||
auth_token: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Optional bearer token — sent as 'Authorization: Bearer <token>'. "
|
||||
"Add a custom Authorization entry in `headers` to override."
|
||||
),
|
||||
)
|
||||
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)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
icon: Optional[str] = Field(None, max_length=64)
|
||||
icon_color: Optional[str] = Field(None, max_length=32)
|
||||
|
||||
@field_validator("headers")
|
||||
@classmethod
|
||||
def _check_headers(cls, value):
|
||||
return _validate_headers(value)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _check_url(cls, value):
|
||||
return _validate_url(value)
|
||||
|
||||
|
||||
class HTTPEndpointUpdate(BaseModel):
|
||||
"""Request to update an HTTP endpoint.
|
||||
|
||||
All fields optional — ``None`` keeps the existing value. Sending an
|
||||
empty string for ``auth_token`` CLEARS the stored token; omit the
|
||||
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)
|
||||
|
||||
@field_validator("headers")
|
||||
@classmethod
|
||||
def _check_headers(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
return _validate_headers(value)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _check_url(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
return _validate_url(value)
|
||||
|
||||
|
||||
class HTTPEndpointResponse(BaseModel):
|
||||
"""HTTP endpoint response. Note: ``auth_token`` is NEVER returned —
|
||||
use ``auth_token_set`` to know whether one is configured."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
url: str
|
||||
method: str
|
||||
auth_token_set: bool = False
|
||||
headers: Dict[str, str] = Field(default_factory=dict)
|
||||
timeout_s: float
|
||||
description: Optional[str] = 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)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class HTTPEndpointListResponse(BaseModel):
|
||||
endpoints: List[HTTPEndpointResponse]
|
||||
count: int
|
||||
|
||||
|
||||
class HTTPTestRequest(BaseModel):
|
||||
"""One-shot test request to validate URL + auth before saving."""
|
||||
|
||||
url: str
|
||||
method: Literal["GET", "HEAD"] = "GET"
|
||||
auth_token: str = ""
|
||||
headers: Dict[str, str] = Field(default_factory=dict)
|
||||
timeout_s: float = Field(default=10.0, gt=0)
|
||||
|
||||
@field_validator("headers")
|
||||
@classmethod
|
||||
def _check_headers(cls, value):
|
||||
return _validate_headers(value)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _check_url(cls, value):
|
||||
return _validate_url(value)
|
||||
|
||||
|
||||
class HTTPTestResponse(BaseModel):
|
||||
success: bool
|
||||
status_code: Optional[int] = None
|
||||
body_preview: Optional[str] = Field(None, description="First 500 chars of the body")
|
||||
body_json: Any = None
|
||||
error: Optional[str] = None
|
||||
@@ -43,6 +43,20 @@ class HALightMappingSchema(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class Z2MLightMappingSchema(BaseModel):
|
||||
"""Maps an LED range to one Zigbee2MQTT bulb (by friendly name)."""
|
||||
|
||||
friendly_name: str = Field(
|
||||
description="Z2M friendly_name (e.g. 'living_room_bulb_1')",
|
||||
min_length=1,
|
||||
)
|
||||
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(
|
||||
default=1.0, description="Brightness multiplier (bindable)"
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Response schemas (per-type, discriminated union)
|
||||
# =====================================================================
|
||||
@@ -119,10 +133,56 @@ class HALightOutputTargetResponse(_OutputTargetResponseBase):
|
||||
)
|
||||
|
||||
|
||||
class Z2MLightOutputTargetResponse(_OutputTargetResponseBase):
|
||||
target_type: Literal["z2m_light"] = "z2m_light"
|
||||
mqtt_source_id: str = Field(
|
||||
default="",
|
||||
description="MQTT source (broker) the target publishes to. Empty = unconfigured.",
|
||||
)
|
||||
source_kind: Literal["css", "color_vs"] = Field(
|
||||
default="css",
|
||||
description="Colour source kind: 'css' (per-mapping LED segments) or "
|
||||
"'color_vs' (single colour value source applied to all bulbs).",
|
||||
)
|
||||
color_strip_source_id: str = Field(
|
||||
default="", description="Color strip source ID (used when source_kind='css')"
|
||||
)
|
||||
color_value_source_id: str = Field(
|
||||
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(
|
||||
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(
|
||||
None, description="Publish rate Hz (bindable; 0.5-10)"
|
||||
)
|
||||
transition: Optional[BindableFloatInput] = Field(
|
||||
None, description="Z2M transition seconds (bindable)"
|
||||
)
|
||||
color_tolerance: Optional[BindableFloatInput] = Field(
|
||||
None, description="RGB delta tolerance (bindable)"
|
||||
)
|
||||
min_brightness_threshold: Optional[BindableFloatInput] = Field(
|
||||
default=0, description="Min brightness threshold (bindable, 0=disabled)"
|
||||
)
|
||||
stop_action: Literal["none", "turn_off"] = Field(
|
||||
default="none",
|
||||
description="What to do with mapped bulbs when the target stops: "
|
||||
"'none' (leave as-is) or 'turn_off'.",
|
||||
)
|
||||
|
||||
|
||||
OutputTargetResponse = Annotated[
|
||||
Union[
|
||||
Annotated[LedOutputTargetResponse, Tag("led")],
|
||||
Annotated[HALightOutputTargetResponse, Tag("ha_light")],
|
||||
Annotated[Z2MLightOutputTargetResponse, Tag("z2m_light")],
|
||||
],
|
||||
Discriminator("target_type"),
|
||||
]
|
||||
@@ -222,10 +282,58 @@ class HALightOutputTargetCreate(_OutputTargetCreateBase):
|
||||
)
|
||||
|
||||
|
||||
class Z2MLightOutputTargetCreate(_OutputTargetCreateBase):
|
||||
target_type: Literal["z2m_light"] = "z2m_light"
|
||||
mqtt_source_id: str = Field(
|
||||
default="",
|
||||
description="MQTT source (broker) the target publishes to. Required to start.",
|
||||
)
|
||||
source_kind: Literal["css", "color_vs"] = Field(
|
||||
default="css",
|
||||
description="Colour source kind: 'css' or 'color_vs'.",
|
||||
)
|
||||
color_strip_source_id: str = Field(
|
||||
default="", description="Color strip source ID (used when source_kind='css')"
|
||||
)
|
||||
color_value_source_id: str = Field(
|
||||
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(
|
||||
None, description="LED-to-bulb mappings (by Z2M friendly_name)"
|
||||
)
|
||||
base_topic: str = Field(
|
||||
default="zigbee2mqtt",
|
||||
max_length=128,
|
||||
description="Z2M MQTT base topic prefix.",
|
||||
)
|
||||
update_rate: Optional[BindableFloatInput] = Field(
|
||||
default=5.0, description="Publish rate in Hz (bindable; 0.5-10)"
|
||||
)
|
||||
transition: Optional[BindableFloatInput] = Field(
|
||||
default=0.3, description="Z2M transition seconds (bindable)"
|
||||
)
|
||||
color_tolerance: Optional[BindableFloatInput] = Field(
|
||||
default=5, description="RGB delta tolerance (bindable)"
|
||||
)
|
||||
min_brightness_threshold: Optional[BindableFloatInput] = Field(
|
||||
default=0,
|
||||
description="Min brightness threshold (bindable, 0=disabled); below this -> off",
|
||||
)
|
||||
stop_action: Literal["none", "turn_off"] = Field(
|
||||
default="none",
|
||||
description="Finalization on stop: 'none' or 'turn_off'.",
|
||||
)
|
||||
|
||||
|
||||
OutputTargetCreate = Annotated[
|
||||
Union[
|
||||
Annotated[LedOutputTargetCreate, Tag("led")],
|
||||
Annotated[HALightOutputTargetCreate, Tag("ha_light")],
|
||||
Annotated[Z2MLightOutputTargetCreate, Tag("z2m_light")],
|
||||
],
|
||||
Discriminator("target_type"),
|
||||
]
|
||||
@@ -309,10 +417,48 @@ class HALightOutputTargetUpdate(_OutputTargetUpdateBase):
|
||||
)
|
||||
|
||||
|
||||
class Z2MLightOutputTargetUpdate(_OutputTargetUpdateBase):
|
||||
target_type: Literal["z2m_light"] = "z2m_light"
|
||||
mqtt_source_id: Optional[str] = Field(
|
||||
None,
|
||||
description="MQTT source (broker) id. Empty string clears the binding.",
|
||||
)
|
||||
source_kind: Optional[Literal["css", "color_vs"]] = 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(
|
||||
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(
|
||||
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(
|
||||
None, description="Publish rate Hz (bindable; 0.5-10)"
|
||||
)
|
||||
transition: Optional[BindableFloatInput] = Field(
|
||||
None, description="Z2M transition seconds (bindable)"
|
||||
)
|
||||
color_tolerance: Optional[BindableFloatInput] = Field(
|
||||
None, description="RGB delta tolerance (bindable)"
|
||||
)
|
||||
min_brightness_threshold: Optional[BindableFloatInput] = Field(
|
||||
None, description="Min brightness threshold (bindable, 0=disabled)"
|
||||
)
|
||||
stop_action: Optional[Literal["none", "turn_off"]] = 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")],
|
||||
],
|
||||
Discriminator("target_type"),
|
||||
]
|
||||
|
||||
@@ -151,6 +151,17 @@ class SystemMetricsValueSourceResponse(_ValueSourceResponseBase):
|
||||
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
|
||||
|
||||
|
||||
class HTTPValueSourceResponse(_ValueSourceResponseBase):
|
||||
source_type: Literal["http"] = "http"
|
||||
return_type: Literal["float"] = "float"
|
||||
http_endpoint_id: str = Field(description="HTTP endpoint ID")
|
||||
json_path: str = Field(description="Dot-path into the response body")
|
||||
interval_s: int = Field(description="Polling cadence (seconds)")
|
||||
min_value: float = Field(description="Raw value mapped to output 0.0")
|
||||
max_value: float = Field(description="Raw value mapped to output 1.0")
|
||||
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
|
||||
|
||||
|
||||
ValueSourceResponse = Annotated[
|
||||
Union[
|
||||
Annotated[StaticValueSourceResponse, Tag("static")],
|
||||
@@ -166,6 +177,7 @@ ValueSourceResponse = Annotated[
|
||||
Annotated[GradientMapValueSourceResponse, Tag("gradient_map")],
|
||||
Annotated[CSSExtractValueSourceResponse, Tag("css_extract")],
|
||||
Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")],
|
||||
Annotated[HTTPValueSourceResponse, Tag("http")],
|
||||
],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
@@ -310,6 +322,16 @@ class SystemMetricsValueSourceCreate(_ValueSourceCreateBase):
|
||||
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class HTTPValueSourceCreate(_ValueSourceCreateBase):
|
||||
source_type: Literal["http"] = "http"
|
||||
http_endpoint_id: str = Field(description="HTTP endpoint ID")
|
||||
json_path: str = Field("", description="Dot-path into the response (empty = raw body text)")
|
||||
interval_s: int = Field(60, description="Polling cadence (seconds)", ge=1)
|
||||
min_value: float = Field(0.0, description="Raw value mapped to output 0.0")
|
||||
max_value: float = Field(100.0, description="Raw value mapped to output 1.0")
|
||||
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
ValueSourceCreate = Annotated[
|
||||
Union[
|
||||
Annotated[StaticValueSourceCreate, Tag("static")],
|
||||
@@ -325,6 +347,7 @@ ValueSourceCreate = Annotated[
|
||||
Annotated[GradientMapValueSourceCreate, Tag("gradient_map")],
|
||||
Annotated[CSSExtractValueSourceCreate, Tag("css_extract")],
|
||||
Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")],
|
||||
Annotated[HTTPValueSourceCreate, Tag("http")],
|
||||
],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
@@ -463,6 +486,16 @@ class SystemMetricsValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
smoothing: Optional[float] = 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)
|
||||
|
||||
|
||||
ValueSourceUpdate = Annotated[
|
||||
Union[
|
||||
Annotated[StaticValueSourceUpdate, Tag("static")],
|
||||
@@ -478,6 +511,7 @@ ValueSourceUpdate = Annotated[
|
||||
Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")],
|
||||
Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")],
|
||||
Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")],
|
||||
Annotated[HTTPValueSourceUpdate, Tag("http")],
|
||||
],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
|
||||
@@ -83,52 +83,10 @@ class StorageConfig(BaseSettings):
|
||||
database_file: str = f"{_DEFAULT_DATA_DIR_STR}/ledgrab.db"
|
||||
|
||||
|
||||
class MQTTConfig(BaseSettings):
|
||||
"""MQTT broker configuration.
|
||||
|
||||
The ``password`` field accepts either plaintext or an ``ENC:v1:`` envelope
|
||||
(see :mod:`ledgrab.utils.secret_box`). Use :func:`resolve_mqtt_password`
|
||||
to obtain the plaintext value at runtime.
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
broker_host: str = "localhost"
|
||||
broker_port: int = 1883
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
client_id: str = "ledgrab"
|
||||
base_topic: str = "ledgrab"
|
||||
|
||||
|
||||
def resolve_mqtt_password(config: "Config | None" = None) -> str:
|
||||
"""Return the plaintext MQTT password.
|
||||
|
||||
Accepts either an ``ENC:v1:`` envelope or legacy plaintext. If
|
||||
plaintext is detected, a warning is logged once per process start
|
||||
so the user knows to migrate.
|
||||
"""
|
||||
from ledgrab.utils import get_logger, secret_box
|
||||
|
||||
log = get_logger(__name__)
|
||||
config = config or get_config()
|
||||
pw = config.mqtt.password or ""
|
||||
if not pw:
|
||||
return ""
|
||||
if secret_box.is_encrypted(pw):
|
||||
try:
|
||||
return secret_box.decrypt(pw)
|
||||
except Exception as exc:
|
||||
log.error("Failed to decrypt MQTT password: %s", exc)
|
||||
return ""
|
||||
# Plaintext — warn (once)
|
||||
if not getattr(resolve_mqtt_password, "_warned", False):
|
||||
log.warning(
|
||||
"MQTT password in config.yaml is stored in plaintext. "
|
||||
"Replace with an encrypted envelope (ENC:v1:...) — see "
|
||||
"ledgrab.utils.secret_box.encrypt()."
|
||||
)
|
||||
resolve_mqtt_password._warned = True # type: ignore[attr-defined]
|
||||
return pw
|
||||
# The legacy single-broker ``MQTTConfig`` block has been removed. Brokers
|
||||
# are now first-class :class:`MQTTSource` entries managed through the UI;
|
||||
# see :mod:`ledgrab.core.mqtt.legacy_migration` for the one-shot upgrade
|
||||
# path that seeds an MQTTSource from any pre-existing ``mqtt:`` YAML block.
|
||||
|
||||
|
||||
class LoggingConfig(BaseSettings):
|
||||
@@ -158,6 +116,10 @@ class Config(BaseSettings):
|
||||
env_prefix="LEDGRAB_",
|
||||
env_nested_delimiter="__",
|
||||
case_sensitive=False,
|
||||
# ``extra="ignore"`` lets pre-existing YAML files (with the now-removed
|
||||
# ``mqtt:`` block, etc.) load without raising. The legacy MQTT block
|
||||
# is handled by ``core.mqtt.legacy_migration`` on first startup.
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
demo: bool = False
|
||||
@@ -166,7 +128,6 @@ class Config(BaseSettings):
|
||||
auth: AuthConfig = Field(default_factory=AuthConfig)
|
||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||
assets: AssetsConfig = Field(default_factory=AssetsConfig)
|
||||
mqtt: MQTTConfig = Field(default_factory=MQTTConfig)
|
||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||
updates: UpdatesConfig = Field(default_factory=UpdatesConfig)
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Optional, Set
|
||||
from typing import Callable, Dict, Optional, Set
|
||||
|
||||
from ledgrab.core.automations.platform_detector import PlatformDetector
|
||||
from ledgrab.storage.automation import (
|
||||
@@ -11,6 +12,7 @@ from ledgrab.storage.automation import (
|
||||
Automation,
|
||||
DisplayStateRule,
|
||||
HomeAssistantRule,
|
||||
HTTPPollRule,
|
||||
MQTTRule,
|
||||
Rule,
|
||||
StartupRule,
|
||||
@@ -25,6 +27,53 @@ from ledgrab.utils import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _RuleEvalContext:
|
||||
"""Per-tick environment passed to every rule handler.
|
||||
|
||||
Bundles all the cross-cutting state the various ``_evaluate_*``
|
||||
handlers need so adding a new handler does not require widening
|
||||
``_evaluate_rule``'s parameter list. ``frozen=True`` guards against
|
||||
a handler mutating its inputs.
|
||||
"""
|
||||
|
||||
running_procs: Set[str]
|
||||
topmost_proc: Optional[str]
|
||||
topmost_fullscreen: bool
|
||||
fullscreen_procs: Set[str]
|
||||
idle_seconds: Optional[float]
|
||||
display_state: Optional[str]
|
||||
|
||||
|
||||
def _apply_operator(operator: str, extracted, expected: str) -> bool:
|
||||
"""Compare *extracted* against *expected* using *operator*.
|
||||
|
||||
String operators (equals, not_equals, contains, regex) coerce the
|
||||
extracted value to str. Numeric operators (gt, lt) coerce both sides
|
||||
to float and return False on parse failure.
|
||||
"""
|
||||
if operator == "equals":
|
||||
return str(extracted) == expected
|
||||
if operator == "not_equals":
|
||||
return str(extracted) != expected
|
||||
if operator == "contains":
|
||||
return expected in str(extracted)
|
||||
if operator == "regex":
|
||||
try:
|
||||
return bool(re.search(expected, str(extracted)))
|
||||
except re.error as exc:
|
||||
logger.debug("HTTP poll rule regex error: %s", exc)
|
||||
return False
|
||||
if operator in ("gt", "lt"):
|
||||
try:
|
||||
lhs = float(extracted)
|
||||
rhs = float(expected)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return lhs > rhs if operator == "gt" else lhs < rhs
|
||||
return False
|
||||
|
||||
|
||||
class AutomationEngine:
|
||||
"""Evaluates automation rules and activates/deactivates scene presets."""
|
||||
|
||||
@@ -33,19 +82,21 @@ class AutomationEngine:
|
||||
automation_store: AutomationStore,
|
||||
processor_manager,
|
||||
poll_interval: float = 1.0,
|
||||
mqtt_service=None,
|
||||
scene_preset_store=None,
|
||||
target_store=None,
|
||||
device_store=None,
|
||||
ha_manager=None,
|
||||
mqtt_manager=None,
|
||||
value_stream_manager=None,
|
||||
value_source_store=None,
|
||||
):
|
||||
self._store = automation_store
|
||||
self._manager = processor_manager
|
||||
self._poll_interval = poll_interval
|
||||
self._detector = PlatformDetector()
|
||||
self._mqtt_service = mqtt_service
|
||||
self._mqtt_manager = mqtt_manager
|
||||
self._value_stream_manager = value_stream_manager
|
||||
self._value_source_store = value_source_store
|
||||
self._scene_preset_store = scene_preset_store
|
||||
self._target_store = target_store
|
||||
self._device_store = device_store
|
||||
@@ -67,12 +118,15 @@ class AutomationEngine:
|
||||
self._ha_acquired: Set[str] = set()
|
||||
# MQTT source IDs currently acquired by the engine
|
||||
self._mqtt_acquired: Set[str] = set()
|
||||
# Value source IDs currently acquired by the engine (for HTTPPollRule)
|
||||
self._value_sources_acquired: Set[str] = set()
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._task is not None:
|
||||
return
|
||||
await self._sync_ha_runtimes()
|
||||
await self._sync_mqtt_runtimes()
|
||||
self._sync_value_stream_refs()
|
||||
self._task = asyncio.create_task(self._poll_loop())
|
||||
logger.info("Automation engine started")
|
||||
|
||||
@@ -96,6 +150,8 @@ class AutomationEngine:
|
||||
await self._release_all_ha_runtimes()
|
||||
# Release all MQTT runtimes
|
||||
await self._release_all_mqtt_runtimes()
|
||||
# Release all value-stream refs held for HTTPPollRule evaluation
|
||||
self._release_all_value_stream_refs()
|
||||
|
||||
logger.info("Automation engine stopped")
|
||||
|
||||
@@ -185,6 +241,53 @@ class AutomationEngine:
|
||||
logger.warning("Failed to release MQTT runtime %s: %s", source_id, e)
|
||||
self._mqtt_acquired = set()
|
||||
|
||||
def _get_needed_value_sources(self) -> Set[str]:
|
||||
"""Collect value source IDs referenced by enabled HTTPPollRule rules."""
|
||||
needed: Set[str] = set()
|
||||
if self._value_stream_manager is None:
|
||||
return needed
|
||||
for a in self._store.get_all_automations():
|
||||
if a.enabled:
|
||||
for r in a.rules:
|
||||
if isinstance(r, HTTPPollRule) and r.value_source_id:
|
||||
needed.add(r.value_source_id)
|
||||
return needed
|
||||
|
||||
def _sync_value_stream_refs(self) -> None:
|
||||
"""Acquire/release ValueStreams to keep HTTPPollRule sources polling.
|
||||
|
||||
Mirrors the HA/MQTT sync pattern, but talks to ``ValueStreamManager``
|
||||
(which is sync). Acquiring a stream both starts its background poll
|
||||
task and pins the ref count; releasing decrements.
|
||||
"""
|
||||
if self._value_stream_manager is None:
|
||||
return
|
||||
needed = self._get_needed_value_sources()
|
||||
for vs_id in self._value_sources_acquired - needed:
|
||||
try:
|
||||
self._value_stream_manager.release(vs_id)
|
||||
logger.debug("Released value stream for automation: %s", vs_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to release value stream %s: %s", vs_id, e)
|
||||
for vs_id in needed - self._value_sources_acquired:
|
||||
try:
|
||||
self._value_stream_manager.acquire(vs_id)
|
||||
logger.debug("Acquired value stream for automation: %s", vs_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to acquire value stream %s: %s", vs_id, e)
|
||||
self._value_sources_acquired = needed
|
||||
|
||||
def _release_all_value_stream_refs(self) -> None:
|
||||
"""Release all ValueStreams held for HTTPPollRule evaluation."""
|
||||
if self._value_stream_manager is None:
|
||||
return
|
||||
for vs_id in self._value_sources_acquired:
|
||||
try:
|
||||
self._value_stream_manager.release(vs_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to release value stream %s: %s", vs_id, e)
|
||||
self._value_sources_acquired = set()
|
||||
|
||||
async def _poll_loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
@@ -200,6 +303,7 @@ class AutomationEngine:
|
||||
async def _evaluate_all(self) -> None:
|
||||
await self._sync_ha_runtimes()
|
||||
await self._sync_mqtt_runtimes()
|
||||
self._sync_value_stream_refs()
|
||||
async with self._eval_lock:
|
||||
await self._evaluate_all_locked()
|
||||
|
||||
@@ -339,6 +443,12 @@ class AutomationEngine:
|
||||
return all(results)
|
||||
return any(results) # "or" is default
|
||||
|
||||
# Per-rule-type handlers. Built once at class-definition time (see
|
||||
# ``_RULE_HANDLERS`` below) so the dispatch dict is not rebuilt on every
|
||||
# tick the way the old inline body used to. Each handler signature is
|
||||
# ``(self, rule, ctx: _RuleEvalContext) -> bool``.
|
||||
_RULE_HANDLERS: "Dict[type, Callable[..., bool]]"
|
||||
|
||||
def _evaluate_rule(
|
||||
self,
|
||||
rule: Rule,
|
||||
@@ -349,22 +459,63 @@ class AutomationEngine:
|
||||
idle_seconds: Optional[float],
|
||||
display_state: Optional[str],
|
||||
) -> bool:
|
||||
dispatch = {
|
||||
StartupRule: lambda r: True,
|
||||
ApplicationRule: lambda r: self._evaluate_app_rule(
|
||||
r, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs
|
||||
),
|
||||
TimeOfDayRule: lambda r: self._evaluate_time_of_day(r),
|
||||
SystemIdleRule: lambda r: self._evaluate_idle(r, idle_seconds),
|
||||
DisplayStateRule: lambda r: self._evaluate_display_state(r, display_state),
|
||||
MQTTRule: lambda r: self._evaluate_mqtt(r),
|
||||
WebhookRule: lambda r: self._webhook_states.get(r.token, False),
|
||||
HomeAssistantRule: lambda r: self._evaluate_home_assistant(r),
|
||||
}
|
||||
handler = dispatch.get(type(rule))
|
||||
ctx = _RuleEvalContext(
|
||||
running_procs=running_procs,
|
||||
topmost_proc=topmost_proc,
|
||||
topmost_fullscreen=topmost_fullscreen,
|
||||
fullscreen_procs=fullscreen_procs,
|
||||
idle_seconds=idle_seconds,
|
||||
display_state=display_state,
|
||||
)
|
||||
handler = self._RULE_HANDLERS.get(type(rule))
|
||||
if handler is None:
|
||||
# Coverage of ``_RULE_HANDLERS`` is asserted at module import,
|
||||
# so reaching this branch means a Rule subclass slipped past
|
||||
# the assertion (e.g. a hand-built test instance). Log loudly
|
||||
# and fall back to the previous "treat as inactive" semantics.
|
||||
logger.warning(
|
||||
"No handler registered for rule type %s — treating as inactive",
|
||||
type(rule).__name__,
|
||||
)
|
||||
return False
|
||||
return handler(rule)
|
||||
return handler(self, rule, ctx)
|
||||
|
||||
# -- Per-rule-type handlers --
|
||||
# Bound to ``self`` via ``_RULE_HANDLERS`` lookup; each signature is
|
||||
# ``(self, rule, ctx: _RuleEvalContext) -> bool``.
|
||||
|
||||
def _handle_startup(self, rule: StartupRule, ctx: _RuleEvalContext) -> bool:
|
||||
return True
|
||||
|
||||
def _handle_application(self, rule: ApplicationRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_app_rule(
|
||||
rule,
|
||||
ctx.running_procs,
|
||||
ctx.topmost_proc,
|
||||
ctx.topmost_fullscreen,
|
||||
ctx.fullscreen_procs,
|
||||
)
|
||||
|
||||
def _handle_time_of_day(self, rule: TimeOfDayRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_time_of_day(rule)
|
||||
|
||||
def _handle_system_idle(self, rule: SystemIdleRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_idle(rule, ctx.idle_seconds)
|
||||
|
||||
def _handle_display_state(self, rule: DisplayStateRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_display_state(rule, ctx.display_state)
|
||||
|
||||
def _handle_mqtt(self, rule: MQTTRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_mqtt(rule)
|
||||
|
||||
def _handle_webhook(self, rule: WebhookRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._webhook_states.get(rule.token, False)
|
||||
|
||||
def _handle_home_assistant(self, rule: HomeAssistantRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_home_assistant(rule)
|
||||
|
||||
def _handle_http_poll(self, rule: HTTPPollRule, ctx: _RuleEvalContext) -> bool:
|
||||
return self._evaluate_http_poll(rule)
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
|
||||
@@ -393,20 +544,14 @@ class AutomationEngine:
|
||||
return display_state == rule.state
|
||||
|
||||
def _evaluate_mqtt(self, rule: MQTTRule) -> bool:
|
||||
value = None
|
||||
# Try entity-based manager first (new model)
|
||||
if self._mqtt_manager is not None and rule.mqtt_source_id:
|
||||
runtime = self._mqtt_manager.get_runtime(rule.mqtt_source_id)
|
||||
if runtime and runtime.is_connected:
|
||||
value = runtime.get_last_value(rule.topic)
|
||||
elif self._mqtt_manager is not None:
|
||||
# No source specified — try first available runtime
|
||||
runtime = self._mqtt_manager.get_first_runtime()
|
||||
if runtime:
|
||||
value = runtime.get_last_value(rule.topic)
|
||||
# Fallback to legacy global service
|
||||
if value is None and self._mqtt_service is not None and self._mqtt_service.is_connected:
|
||||
value = self._mqtt_service.get_last_value(rule.topic)
|
||||
# Multi-broker model: the rule references a specific MQTTSource.
|
||||
# Rules without one are no-ops (UI should enforce a source on save).
|
||||
if self._mqtt_manager is None or not rule.mqtt_source_id:
|
||||
return False
|
||||
runtime = self._mqtt_manager.get_runtime(rule.mqtt_source_id)
|
||||
if runtime is None or not runtime.is_connected:
|
||||
return False
|
||||
value = runtime.get_last_value(rule.topic)
|
||||
if value is None:
|
||||
return False
|
||||
matchers = {
|
||||
@@ -444,6 +589,25 @@ class AutomationEngine:
|
||||
logger.debug("HA rule regex error: %s", e)
|
||||
return False
|
||||
|
||||
def _evaluate_http_poll(self, rule: HTTPPollRule) -> bool:
|
||||
"""Evaluate an HTTPPollRule by reading the referenced value source.
|
||||
|
||||
The value source (HTTPValueSource → HTTPValueStream) handles the
|
||||
actual polling + JSON extraction; the rule only compares the
|
||||
already-extracted raw value to ``rule.value`` via ``operator``.
|
||||
"""
|
||||
if self._value_stream_manager is None or not rule.value_source_id:
|
||||
return False
|
||||
stream = self._value_stream_manager.peek(rule.value_source_id)
|
||||
if stream is None or not hasattr(stream, "get_raw_value"):
|
||||
return False
|
||||
raw = stream.get_raw_value()
|
||||
if rule.operator == "exists":
|
||||
return raw is not None
|
||||
if raw is None:
|
||||
return False
|
||||
return _apply_operator(rule.operator, raw, rule.value)
|
||||
|
||||
def _evaluate_app_rule(
|
||||
self,
|
||||
rule: ApplicationRule,
|
||||
@@ -644,3 +808,57 @@ class AutomationEngine:
|
||||
"""Deactivate an automation immediately (used when disabling/deleting)."""
|
||||
if automation_id in self._active_automations:
|
||||
await self._deactivate_automation(automation_id)
|
||||
|
||||
|
||||
# Bind the per-rule-type handler table once after the class is fully defined.
|
||||
# This replaces the per-call dict-rebuild that the inline ``_evaluate_rule``
|
||||
# used to do and gives us a single place to assert coverage against the
|
||||
# Rule subclass set imported from storage.
|
||||
AutomationEngine._RULE_HANDLERS = {
|
||||
StartupRule: AutomationEngine._handle_startup,
|
||||
ApplicationRule: AutomationEngine._handle_application,
|
||||
TimeOfDayRule: AutomationEngine._handle_time_of_day,
|
||||
SystemIdleRule: AutomationEngine._handle_system_idle,
|
||||
DisplayStateRule: AutomationEngine._handle_display_state,
|
||||
MQTTRule: AutomationEngine._handle_mqtt,
|
||||
WebhookRule: AutomationEngine._handle_webhook,
|
||||
HomeAssistantRule: AutomationEngine._handle_home_assistant,
|
||||
HTTPPollRule: AutomationEngine._handle_http_poll,
|
||||
}
|
||||
|
||||
|
||||
def _assert_rule_handler_coverage() -> None:
|
||||
"""Every concrete Rule subclass imported by this module must have a handler.
|
||||
|
||||
Runs at module import so a new Rule subclass added without an
|
||||
accompanying ``_handle_*`` method + ``_RULE_HANDLERS`` entry fails the
|
||||
server boot loudly instead of silently being dropped on the floor by
|
||||
``_evaluate_rule``'s "no handler → False" fallback.
|
||||
"""
|
||||
expected = {
|
||||
StartupRule,
|
||||
ApplicationRule,
|
||||
TimeOfDayRule,
|
||||
SystemIdleRule,
|
||||
DisplayStateRule,
|
||||
MQTTRule,
|
||||
WebhookRule,
|
||||
HomeAssistantRule,
|
||||
HTTPPollRule,
|
||||
}
|
||||
registered = set(AutomationEngine._RULE_HANDLERS.keys())
|
||||
missing = expected - registered
|
||||
extra = registered - expected
|
||||
if missing or extra:
|
||||
problems = []
|
||||
if missing:
|
||||
problems.append(f"missing handlers: {sorted(c.__name__ for c in missing)}")
|
||||
if extra:
|
||||
problems.append(f"unregistered classes: {sorted(c.__name__ for c in extra)}")
|
||||
raise RuntimeError(
|
||||
"AutomationEngine._RULE_HANDLERS is out of sync with imported Rule subclasses: "
|
||||
+ "; ".join(problems)
|
||||
)
|
||||
|
||||
|
||||
_assert_rule_handler_coverage()
|
||||
|
||||
@@ -5,6 +5,10 @@ from typing import Dict, List, Literal, Set, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.capture.edge_interpolation import (
|
||||
average_edge_to_leds,
|
||||
fallback_edge_to_leds,
|
||||
)
|
||||
from ledgrab.core.capture.screen_capture import (
|
||||
BorderPixels,
|
||||
calculate_average_color,
|
||||
@@ -404,104 +408,17 @@ class PixelMapper:
|
||||
self, edge_pixels: np.ndarray, edge_name: str, led_count: int
|
||||
) -> np.ndarray:
|
||||
"""Per-LED color mapping for median/dominant modes. Returns (led_count, 3) uint8."""
|
||||
if edge_name in ("top", "bottom"):
|
||||
edge_len = edge_pixels.shape[1]
|
||||
else:
|
||||
edge_len = edge_pixels.shape[0]
|
||||
|
||||
step = edge_len / led_count
|
||||
result = np.empty((led_count, 3), dtype=np.uint8)
|
||||
|
||||
for i in range(led_count):
|
||||
start = int(i * step)
|
||||
end = max(start + 1, int((i + 1) * step))
|
||||
end = min(end, edge_len)
|
||||
|
||||
if edge_name in ("top", "bottom"):
|
||||
segment = edge_pixels[:, start:end, :]
|
||||
else:
|
||||
segment = edge_pixels[start:end, :, :]
|
||||
|
||||
color = self._calc_color(segment)
|
||||
result[i] = color
|
||||
|
||||
return result
|
||||
return fallback_edge_to_leds(edge_pixels, edge_name, led_count, self._calc_color)
|
||||
|
||||
def _map_edge_average(
|
||||
self, edge_pixels: np.ndarray, edge_name: str, led_count: int
|
||||
) -> np.ndarray:
|
||||
"""Vectorized average-color mapping for one edge. Returns (led_count, 3) uint8.
|
||||
|
||||
Uses pre-allocated cumsum/mean buffers AND pre-allocated output
|
||||
buffers (lazy-initialized per edge). All per-frame numpy ops write
|
||||
in-place — zero allocations on the hot path.
|
||||
Scratch buffers are cached on ``self._edge_cache`` keyed by edge name;
|
||||
the shared kernel handles all allocations on first use.
|
||||
"""
|
||||
if edge_name in ("top", "bottom"):
|
||||
axis = 0
|
||||
edge_len = edge_pixels.shape[1]
|
||||
else:
|
||||
axis = 1
|
||||
edge_len = edge_pixels.shape[0]
|
||||
|
||||
# Lazy-init / resize per-edge scratch buffers
|
||||
cache = self._edge_cache.get(edge_name)
|
||||
if cache is None or cache[0] != edge_len or cache[1] != led_count:
|
||||
step = edge_len / led_count
|
||||
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
|
||||
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
|
||||
np.minimum(boundaries, edge_len, out=boundaries)
|
||||
starts = boundaries[:-1]
|
||||
ends = boundaries[1:]
|
||||
lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
|
||||
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
|
||||
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64)
|
||||
sums_buf = np.empty((led_count, 3), dtype=np.float64)
|
||||
starts_buf = np.empty((led_count, 3), dtype=np.float64)
|
||||
out_uint8 = np.empty((led_count, 3), dtype=np.uint8)
|
||||
cache = (
|
||||
edge_len,
|
||||
led_count,
|
||||
starts,
|
||||
ends,
|
||||
lengths,
|
||||
cumsum_buf,
|
||||
edge_1d_buf,
|
||||
sums_buf,
|
||||
starts_buf,
|
||||
out_uint8,
|
||||
)
|
||||
self._edge_cache[edge_name] = cache
|
||||
|
||||
(
|
||||
_,
|
||||
_,
|
||||
starts,
|
||||
ends,
|
||||
lengths,
|
||||
cumsum_buf,
|
||||
edge_1d_buf,
|
||||
sums_buf,
|
||||
starts_buf,
|
||||
out_uint8,
|
||||
) = cache
|
||||
|
||||
# Mean into pre-allocated buffer (no intermediate float64 array)
|
||||
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
|
||||
|
||||
# Cumsum into pre-allocated buffer (cumsum_buf[0] left at 0 from init)
|
||||
cumsum_buf[0] = 0
|
||||
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
|
||||
|
||||
# segment_sums = cumsum_buf[ends] - cumsum_buf[starts] — but each
|
||||
# fancy-index expression allocates. np.take with ``out=`` writes
|
||||
# directly into our pre-allocated scratch.
|
||||
np.take(cumsum_buf, ends, axis=0, out=sums_buf)
|
||||
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
|
||||
np.subtract(sums_buf, starts_buf, out=sums_buf)
|
||||
np.divide(sums_buf, lengths, out=sums_buf)
|
||||
np.clip(sums_buf, 0, 255, out=sums_buf)
|
||||
np.copyto(out_uint8, sums_buf, casting="unsafe")
|
||||
return out_uint8
|
||||
return average_edge_to_leds(edge_pixels, edge_name, led_count, self._edge_cache, edge_name)
|
||||
|
||||
def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray:
|
||||
"""Map screen border pixels to LED colors.
|
||||
@@ -666,64 +583,12 @@ class AdvancedPixelMapper:
|
||||
led_count: int,
|
||||
cache_key: int,
|
||||
) -> np.ndarray:
|
||||
"""Vectorized average-color mapping (same algo as PixelMapper)."""
|
||||
if edge_name in ("top", "bottom"):
|
||||
axis = 0
|
||||
edge_len = edge_pixels.shape[1]
|
||||
else:
|
||||
axis = 1
|
||||
edge_len = edge_pixels.shape[0]
|
||||
"""Vectorized average-color mapping; delegates to the shared kernel.
|
||||
|
||||
cache = self._edge_cache.get(cache_key)
|
||||
if cache is None or cache[0] != edge_len or cache[1] != led_count:
|
||||
step = edge_len / led_count
|
||||
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
|
||||
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
|
||||
np.minimum(boundaries, edge_len, out=boundaries)
|
||||
starts = boundaries[:-1]
|
||||
ends = boundaries[1:]
|
||||
lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
|
||||
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
|
||||
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64)
|
||||
sums_buf = np.empty((led_count, 3), dtype=np.float64)
|
||||
starts_buf = np.empty((led_count, 3), dtype=np.float64)
|
||||
out_uint8 = np.empty((led_count, 3), dtype=np.uint8)
|
||||
cache = (
|
||||
edge_len,
|
||||
led_count,
|
||||
starts,
|
||||
ends,
|
||||
lengths,
|
||||
cumsum_buf,
|
||||
edge_1d_buf,
|
||||
sums_buf,
|
||||
starts_buf,
|
||||
out_uint8,
|
||||
)
|
||||
self._edge_cache[cache_key] = cache
|
||||
|
||||
(
|
||||
_,
|
||||
_,
|
||||
starts,
|
||||
ends,
|
||||
lengths,
|
||||
cumsum_buf,
|
||||
edge_1d_buf,
|
||||
sums_buf,
|
||||
starts_buf,
|
||||
out_uint8,
|
||||
) = cache
|
||||
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
|
||||
cumsum_buf[0] = 0
|
||||
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
|
||||
np.take(cumsum_buf, ends, axis=0, out=sums_buf)
|
||||
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
|
||||
np.subtract(sums_buf, starts_buf, out=sums_buf)
|
||||
np.divide(sums_buf, lengths, out=sums_buf)
|
||||
np.clip(sums_buf, 0, 255, out=sums_buf)
|
||||
np.copyto(out_uint8, sums_buf, casting="unsafe")
|
||||
return out_uint8
|
||||
``cache_key`` is an integer (e.g. line index) so multiple per-line
|
||||
edges can share the same ``self._edge_cache`` dict without colliding.
|
||||
"""
|
||||
return average_edge_to_leds(edge_pixels, edge_name, led_count, self._edge_cache, cache_key)
|
||||
|
||||
def _map_edge_fallback(
|
||||
self,
|
||||
@@ -731,24 +596,8 @@ class AdvancedPixelMapper:
|
||||
edge_name: str,
|
||||
led_count: int,
|
||||
) -> np.ndarray:
|
||||
"""Per-LED color mapping for median/dominant modes."""
|
||||
if edge_name in ("top", "bottom"):
|
||||
edge_len = edge_pixels.shape[1]
|
||||
else:
|
||||
edge_len = edge_pixels.shape[0]
|
||||
|
||||
step = edge_len / led_count
|
||||
result = np.empty((led_count, 3), dtype=np.uint8)
|
||||
for i in range(led_count):
|
||||
start = int(i * step)
|
||||
end = max(start + 1, int((i + 1) * step))
|
||||
end = min(end, edge_len)
|
||||
if edge_name in ("top", "bottom"):
|
||||
segment = edge_pixels[:, start:end, :]
|
||||
else:
|
||||
segment = edge_pixels[start:end, :, :]
|
||||
result[i] = self._calc_color(segment)
|
||||
return result
|
||||
"""Per-LED color mapping for median/dominant modes; delegates to shared kernel."""
|
||||
return fallback_edge_to_leds(edge_pixels, edge_name, led_count, self._calc_color)
|
||||
|
||||
def map_lines_to_leds(self, frames: Dict[str, np.ndarray]) -> np.ndarray:
|
||||
"""Map multi-source frames to LED colors using calibration lines.
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Shared edge-to-LED interpolation kernels for PixelMapper variants.
|
||||
|
||||
``PixelMapper`` and ``AdvancedPixelMapper`` in ``calibration.py`` historically
|
||||
carried two byte-for-byte copies of:
|
||||
|
||||
* the fast vectorised "average across each LED segment" path
|
||||
(``_map_edge_average``) — ~80 lines of buffer-allocation + cumsum tricks; and
|
||||
* the per-LED-loop "median / dominant colour" path (``_map_edge_fallback``).
|
||||
|
||||
Lifting both kernels into pure functions removes the duplication and
|
||||
keeps the algorithms in one place. Each mapper owns its own scratch-buffer
|
||||
cache (keyed differently in the two cases — see callers); the functions
|
||||
accept that cache as an in/out dict so allocations still happen once per
|
||||
(edge_len, led_count) pair.
|
||||
|
||||
These functions intentionally do NOT touch the mappers' state beyond what
|
||||
the callers pass in, so they are trivially testable in isolation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, Hashable, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Cache value layout — kept as a tuple for the small per-frame cost of
|
||||
# tuple unpacking vs the readability of a dataclass. The first two entries
|
||||
# are the (edge_len, led_count) signature used to detect a re-build.
|
||||
_CacheEntry = Tuple[
|
||||
int, # edge_len
|
||||
int, # led_count
|
||||
np.ndarray, # starts (int64, shape (led_count,))
|
||||
np.ndarray, # ends (int64, shape (led_count,))
|
||||
np.ndarray, # lengths (float32, shape (led_count, 1))
|
||||
np.ndarray, # cumsum_buf (float32, shape (edge_len + 1, 3))
|
||||
np.ndarray, # edge_1d_buf (float32, shape (edge_len, 3))
|
||||
np.ndarray, # sums_buf (float32, shape (led_count, 3))
|
||||
np.ndarray, # starts_buf (float32, shape (led_count, 3))
|
||||
np.ndarray, # out_uint8 (uint8, shape (led_count, 3))
|
||||
]
|
||||
|
||||
|
||||
def _build_cache(edge_len: int, led_count: int) -> _CacheEntry:
|
||||
"""Pre-allocate all scratch buffers for one (edge_len, led_count) pair."""
|
||||
step = edge_len / led_count
|
||||
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
|
||||
# Ensure monotonically increasing boundaries even when ``step`` < 1.
|
||||
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
|
||||
np.minimum(boundaries, edge_len, out=boundaries)
|
||||
starts = boundaries[:-1]
|
||||
ends = boundaries[1:]
|
||||
lengths = (ends - starts).reshape(-1, 1).astype(np.float32)
|
||||
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float32)
|
||||
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float32)
|
||||
sums_buf = np.empty((led_count, 3), dtype=np.float32)
|
||||
starts_buf = np.empty((led_count, 3), dtype=np.float32)
|
||||
out_uint8 = np.empty((led_count, 3), dtype=np.uint8)
|
||||
return (
|
||||
edge_len,
|
||||
led_count,
|
||||
starts,
|
||||
ends,
|
||||
lengths,
|
||||
cumsum_buf,
|
||||
edge_1d_buf,
|
||||
sums_buf,
|
||||
starts_buf,
|
||||
out_uint8,
|
||||
)
|
||||
|
||||
|
||||
def average_edge_to_leds(
|
||||
edge_pixels: np.ndarray,
|
||||
edge_name: str,
|
||||
led_count: int,
|
||||
cache: Dict[Hashable, _CacheEntry],
|
||||
cache_key: Hashable,
|
||||
) -> np.ndarray:
|
||||
"""Vectorised average colour per LED segment.
|
||||
|
||||
``edge_pixels`` is shape ``(H, W, 3)``. For top/bottom edges we average
|
||||
over axis=0 (collapsing rows), then segment along the width; for
|
||||
left/right edges we average over axis=1 then segment along the height.
|
||||
|
||||
Returns a view into the caller-owned cache's ``out_uint8`` buffer —
|
||||
do NOT retain the result across calls without copying.
|
||||
"""
|
||||
if edge_name in ("top", "bottom"):
|
||||
axis = 0
|
||||
edge_len = edge_pixels.shape[1]
|
||||
else:
|
||||
axis = 1
|
||||
edge_len = edge_pixels.shape[0]
|
||||
|
||||
entry = cache.get(cache_key)
|
||||
if entry is None or entry[0] != edge_len or entry[1] != led_count:
|
||||
entry = _build_cache(edge_len, led_count)
|
||||
cache[cache_key] = entry
|
||||
|
||||
(
|
||||
_,
|
||||
_,
|
||||
starts,
|
||||
ends,
|
||||
lengths,
|
||||
cumsum_buf,
|
||||
edge_1d_buf,
|
||||
sums_buf,
|
||||
starts_buf,
|
||||
out_uint8,
|
||||
) = entry
|
||||
|
||||
# Mean into pre-allocated buffer (no intermediate float64 array)
|
||||
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
|
||||
|
||||
# Cumulative sum so each LED segment's sum is two array lookups apart.
|
||||
cumsum_buf[0] = 0
|
||||
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
|
||||
|
||||
# segment_sum[i] = cumsum[ends[i]] - cumsum[starts[i]]
|
||||
np.take(cumsum_buf, ends, axis=0, out=sums_buf)
|
||||
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
|
||||
np.subtract(sums_buf, starts_buf, out=sums_buf)
|
||||
np.divide(sums_buf, lengths, out=sums_buf)
|
||||
np.clip(sums_buf, 0, 255, out=sums_buf)
|
||||
np.copyto(out_uint8, sums_buf, casting="unsafe")
|
||||
return out_uint8
|
||||
|
||||
|
||||
def fallback_edge_to_leds(
|
||||
edge_pixels: np.ndarray,
|
||||
edge_name: str,
|
||||
led_count: int,
|
||||
calc_color: Callable[[np.ndarray], Any],
|
||||
) -> np.ndarray:
|
||||
"""Per-LED colour mapping for median / dominant modes.
|
||||
|
||||
Iterates LED segments and delegates colour reduction to ``calc_color``
|
||||
(which is e.g. ``np.median`` for median mode, ``_dominant_colour`` for
|
||||
dominant). Slower than ``average_edge_to_leds`` but supports any
|
||||
reducer over the segment's pixels.
|
||||
"""
|
||||
if edge_name in ("top", "bottom"):
|
||||
edge_len = edge_pixels.shape[1]
|
||||
else:
|
||||
edge_len = edge_pixels.shape[0]
|
||||
|
||||
step = edge_len / led_count
|
||||
result = np.empty((led_count, 3), dtype=np.uint8)
|
||||
|
||||
for i in range(led_count):
|
||||
start = int(i * step)
|
||||
end = max(start + 1, int((i + 1) * step))
|
||||
end = min(end, edge_len)
|
||||
|
||||
if edge_name in ("top", "bottom"):
|
||||
segment = edge_pixels[:, start:end, :]
|
||||
else:
|
||||
segment = edge_pixels[start:end, :, :]
|
||||
|
||||
result[i] = calc_color(segment)
|
||||
|
||||
return result
|
||||
@@ -14,6 +14,12 @@ from ledgrab.utils import get_logger, get_monitor_names, get_monitor_refresh_rat
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Reused random Generator for sampling. The legacy ``np.random.randint``
|
||||
# uses the module-level RandomState which is slightly slower per-call and
|
||||
# pulls in extra import-time work; a single Generator is faster and avoids
|
||||
# global-state surprises.
|
||||
_rng = np.random.default_rng()
|
||||
|
||||
|
||||
@dataclass
|
||||
class DisplayInfo:
|
||||
@@ -192,8 +198,11 @@ def extract_border_pixels(screen_capture: ScreenCapture, border_width: int = 10)
|
||||
left = img[:, :border_width, :]
|
||||
|
||||
logger.debug(
|
||||
f"Extracted borders: top={top.shape}, right={right.shape}, "
|
||||
f"bottom={bottom.shape}, left={left.shape}"
|
||||
"Extracted borders",
|
||||
top=top.shape,
|
||||
right=right.shape,
|
||||
bottom=bottom.shape,
|
||||
left=left.shape,
|
||||
)
|
||||
|
||||
return BorderPixels(
|
||||
@@ -303,6 +312,12 @@ def calculate_median_color(pixels: np.ndarray) -> tuple[int, int, int]:
|
||||
def calculate_dominant_color(pixels: np.ndarray) -> tuple[int, int, int]:
|
||||
"""Calculate dominant color of a pixel region using simple clustering.
|
||||
|
||||
Quantizes to 32 levels/channel (5 bits/channel = 32K bins), packs into a
|
||||
single uint32, then uses ``np.bincount`` to find the most common bin.
|
||||
Sampling uses with-replacement (statistically equivalent for a dominant-bin
|
||||
search and avoids the full sort that ``np.random.choice(replace=False)``
|
||||
triggers internally).
|
||||
|
||||
Args:
|
||||
pixels: Pixel array (height, width, 3)
|
||||
|
||||
@@ -312,28 +327,27 @@ def calculate_dominant_color(pixels: np.ndarray) -> tuple[int, int, int]:
|
||||
if pixels.size == 0:
|
||||
return (0, 0, 0)
|
||||
|
||||
# Reshape to (n_pixels, 3)
|
||||
pixels_reshaped = pixels.reshape(-1, 3)
|
||||
n = len(pixels_reshaped)
|
||||
|
||||
# For performance, sample pixels if there are too many
|
||||
max_samples = 1000
|
||||
if len(pixels_reshaped) > max_samples:
|
||||
indices = np.random.choice(len(pixels_reshaped), max_samples, replace=False)
|
||||
if n > max_samples:
|
||||
# ``Generator.integers`` writes into a fresh buffer once per call;
|
||||
# the legacy ``np.random.randint`` did the same plus extra
|
||||
# bookkeeping. Random (not stride) sampling stays robust against
|
||||
# periodic patterns in screen pixels.
|
||||
indices = _rng.integers(0, n, size=max_samples)
|
||||
pixels_reshaped = pixels_reshaped[indices]
|
||||
|
||||
# Simple dominant color: quantize colors and find most common
|
||||
# Reduce color space to 32 levels per channel for binning
|
||||
quantized = (pixels_reshaped // 8) * 8
|
||||
|
||||
# Find unique colors and their counts
|
||||
unique_colors, counts = np.unique(quantized, axis=0, return_counts=True)
|
||||
|
||||
# Get the most common color
|
||||
dominant_idx = np.argmax(counts)
|
||||
dominant_color = unique_colors[dominant_idx]
|
||||
|
||||
r = int(np.clip(dominant_color[0], 0, 255))
|
||||
g = int(np.clip(dominant_color[1], 0, 255))
|
||||
b = int(np.clip(dominant_color[2], 0, 255))
|
||||
# Quantize to 32 levels/channel (drop low 3 bits) and pack into uint32:
|
||||
# bits 10-14 = R, bits 5-9 = G, bits 0-4 = B → 32K possible bins.
|
||||
q = pixels_reshaped >> 3 # uint8 in [0,31]
|
||||
packed = (q[:, 0].astype(np.uint32) << 10) | (q[:, 1].astype(np.uint32) << 5) | q[:, 2]
|
||||
counts = np.bincount(packed, minlength=1)
|
||||
dominant_bin = int(np.argmax(counts))
|
||||
|
||||
# Reconstruct 5-bit channels and shift back to 8-bit (centered in bin).
|
||||
r = ((dominant_bin >> 10) & 0x1F) << 3
|
||||
g = ((dominant_bin >> 5) & 0x1F) << 3
|
||||
b = (dominant_bin & 0x1F) << 3
|
||||
return (r, g, b)
|
||||
|
||||
@@ -35,12 +35,17 @@ class BetterCamCaptureStream(CaptureStream):
|
||||
except ImportError:
|
||||
raise RuntimeError("BetterCam not installed. Install with: pip install bettercam")
|
||||
|
||||
# Clear global camera cache for fresh DXGI state
|
||||
try:
|
||||
self._bettercam.__factory.clean_up()
|
||||
except Exception as e:
|
||||
logger.debug("BetterCam factory cleanup on init: %s", e)
|
||||
pass
|
||||
# Clear global camera cache for fresh DXGI state.
|
||||
# NOTE: ``self._bettercam.__factory`` is name-mangled by Python to
|
||||
# ``self._bettercam._BetterCamCaptureStream__factory`` because the
|
||||
# access appears inside a class body, which silently AttributeErrors.
|
||||
# Use string-based getattr to bypass mangling.
|
||||
_factory = getattr(self._bettercam, "__factory", None)
|
||||
if _factory is not None:
|
||||
try:
|
||||
_factory.clean_up()
|
||||
except Exception as e:
|
||||
logger.debug("BetterCam factory cleanup on init failed", error=str(e))
|
||||
|
||||
self._camera = self._bettercam.create(
|
||||
output_idx=self.display_index,
|
||||
@@ -71,11 +76,12 @@ class BetterCamCaptureStream(CaptureStream):
|
||||
self._camera = None
|
||||
|
||||
if self._bettercam:
|
||||
try:
|
||||
self._bettercam.__factory.clean_up()
|
||||
except Exception as e:
|
||||
logger.debug("BetterCam factory cleanup on teardown: %s", e)
|
||||
pass
|
||||
_factory = getattr(self._bettercam, "__factory", None)
|
||||
if _factory is not None:
|
||||
try:
|
||||
_factory.clean_up()
|
||||
except Exception as e:
|
||||
logger.debug("BetterCam factory cleanup on teardown failed", error=str(e))
|
||||
|
||||
self._initialized = False
|
||||
logger.info(f"BetterCam capture stream cleaned up (display={self.display_index})")
|
||||
@@ -109,8 +115,10 @@ class BetterCamCaptureStream(CaptureStream):
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
f"BetterCam captured display {self.display_index}: "
|
||||
f"{frame.shape[1]}x{frame.shape[0]}"
|
||||
"BetterCam captured frame",
|
||||
display=self.display_index,
|
||||
w=frame.shape[1],
|
||||
h=frame.shape[0],
|
||||
)
|
||||
|
||||
return ScreenCapture(
|
||||
|
||||
@@ -35,12 +35,17 @@ class DXcamCaptureStream(CaptureStream):
|
||||
except ImportError:
|
||||
raise RuntimeError("DXcam not installed. Install with: pip install dxcam")
|
||||
|
||||
# Clear global camera cache for fresh DXGI state
|
||||
try:
|
||||
self._dxcam.__factory.clean_up()
|
||||
except Exception as e:
|
||||
logger.debug("DXcam factory cleanup on init: %s", e)
|
||||
pass
|
||||
# Clear global camera cache for fresh DXGI state.
|
||||
# NOTE: ``self._dxcam.__factory`` is name-mangled by Python to
|
||||
# ``self._dxcam._DXcamCaptureStream__factory`` because the access
|
||||
# appears inside a class body, which silently AttributeErrors.
|
||||
# Use string-based getattr to bypass mangling.
|
||||
_factory = getattr(self._dxcam, "__factory", None)
|
||||
if _factory is not None:
|
||||
try:
|
||||
_factory.clean_up()
|
||||
except Exception as e:
|
||||
logger.debug("DXcam factory cleanup on init failed", error=str(e))
|
||||
|
||||
self._camera = self._dxcam.create(
|
||||
output_idx=self.display_index,
|
||||
@@ -69,11 +74,12 @@ class DXcamCaptureStream(CaptureStream):
|
||||
self._camera = None
|
||||
|
||||
if self._dxcam:
|
||||
try:
|
||||
self._dxcam.__factory.clean_up()
|
||||
except Exception as e:
|
||||
logger.debug("DXcam factory cleanup on teardown: %s", e)
|
||||
pass
|
||||
_factory = getattr(self._dxcam, "__factory", None)
|
||||
if _factory is not None:
|
||||
try:
|
||||
_factory.clean_up()
|
||||
except Exception as e:
|
||||
logger.debug("DXcam factory cleanup on teardown failed", error=str(e))
|
||||
|
||||
self._initialized = False
|
||||
logger.info(f"DXcam capture stream cleaned up (display={self.display_index})")
|
||||
@@ -107,8 +113,10 @@ class DXcamCaptureStream(CaptureStream):
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
f"DXcam captured display {self.display_index}: "
|
||||
f"{frame.shape[1]}x{frame.shape[0]}"
|
||||
"DXcam captured frame",
|
||||
display=self.display_index,
|
||||
w=frame.shape[1],
|
||||
h=frame.shape[0],
|
||||
)
|
||||
|
||||
return ScreenCapture(
|
||||
|
||||
@@ -5,6 +5,13 @@ from typing import Any, Dict, List, Optional
|
||||
import mss
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import cv2
|
||||
|
||||
_HAS_CV2 = True
|
||||
except ImportError:
|
||||
_HAS_CV2 = False
|
||||
|
||||
from ledgrab.core.capture_engines.base import (
|
||||
CaptureEngine,
|
||||
CaptureStream,
|
||||
@@ -15,6 +22,13 @@ from ledgrab.utils import get_logger, get_monitor_names, get_monitor_refresh_rat
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Rotating RGB output pool: keeps prior frame references stable for any
|
||||
# consumer still reading them while a new frame is written.
|
||||
_RGB_POOL_SIZE = 3
|
||||
# Number of bytes from .raw to hash for change detection (cheap pre-check
|
||||
# that avoids the full BGRA→RGB conversion when the screen is idle).
|
||||
_CHANGE_DETECT_BYTES = 256
|
||||
|
||||
|
||||
class MSSCaptureStream(CaptureStream):
|
||||
"""MSS capture stream for a specific display."""
|
||||
@@ -22,6 +36,12 @@ class MSSCaptureStream(CaptureStream):
|
||||
def __init__(self, display_index: int, config: Dict[str, Any]):
|
||||
super().__init__(display_index, config)
|
||||
self._sct = None
|
||||
# Pre-allocated RGB destination pool — avoids per-frame allocation.
|
||||
self._rgb_pool: list = [None] * _RGB_POOL_SIZE
|
||||
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
|
||||
|
||||
def initialize(self) -> None:
|
||||
try:
|
||||
@@ -36,6 +56,7 @@ class MSSCaptureStream(CaptureStream):
|
||||
self._sct.close()
|
||||
self._sct = None
|
||||
self._initialized = False
|
||||
self._prev_hash = None
|
||||
logger.info(f"MSS capture stream cleaned up (display={self.display_index})")
|
||||
|
||||
def capture_frame(self) -> Optional[ScreenCapture]:
|
||||
@@ -55,18 +76,51 @@ class MSSCaptureStream(CaptureStream):
|
||||
monitor = self._sct.monitors[monitor_index]
|
||||
screenshot = self._sct.grab(monitor)
|
||||
|
||||
# Direct bytes→numpy (skips PIL intermediate object)
|
||||
img_array = np.frombuffer(
|
||||
screenshot.rgb,
|
||||
dtype=np.uint8,
|
||||
).reshape(screenshot.height, screenshot.width, 3)
|
||||
# Cheap change detection: hash a small slice of the raw BGRA
|
||||
# buffer. ~256 bytes is enough to differentiate any cursor/pixel
|
||||
# change. Skips the BGRA→RGB conversion when nothing changed
|
||||
# (common on idle desktops). DXcam/BetterCam return None in this
|
||||
# case natively; mss does not, so we add it here.
|
||||
raw = screenshot.raw
|
||||
sample = bytes(raw[:_CHANGE_DETECT_BYTES])
|
||||
cur_hash = hash(sample)
|
||||
if cur_hash == self._prev_hash:
|
||||
return None
|
||||
self._prev_hash = cur_hash
|
||||
|
||||
height = screenshot.height
|
||||
width = screenshot.width
|
||||
|
||||
# Reshape .raw (BGRA) — zero-copy view over the screenshot's buffer.
|
||||
# ``screenshot.rgb`` (used previously) is a pure-Python BGRA→RGB
|
||||
# rebuild costing ~6 MB/frame at 1080p in the slowest possible
|
||||
# way. cv2.cvtColor is SIMD and writes directly into our pool.
|
||||
bgra = np.frombuffer(raw, dtype=np.uint8).reshape(height, width, 4)
|
||||
|
||||
if self._rgb_shape != (height, width):
|
||||
for i in range(_RGB_POOL_SIZE):
|
||||
self._rgb_pool[i] = np.empty((height, width, 3), dtype=np.uint8)
|
||||
self._rgb_shape = (height, width)
|
||||
|
||||
dst = self._rgb_pool[self._rgb_idx]
|
||||
self._rgb_idx = (self._rgb_idx + 1) % _RGB_POOL_SIZE
|
||||
|
||||
if _HAS_CV2:
|
||||
cv2.cvtColor(bgra, cv2.COLOR_BGRA2RGB, dst=dst)
|
||||
else:
|
||||
dst[..., 0] = bgra[..., 2]
|
||||
dst[..., 1] = bgra[..., 1]
|
||||
dst[..., 2] = bgra[..., 0]
|
||||
|
||||
logger.debug(
|
||||
f"MSS captured display {self.display_index}: {monitor['width']}x{monitor['height']}"
|
||||
"MSS captured frame",
|
||||
display=self.display_index,
|
||||
w=monitor["width"],
|
||||
h=monitor["height"],
|
||||
)
|
||||
|
||||
return ScreenCapture(
|
||||
image=img_array,
|
||||
image=dst,
|
||||
width=monitor["width"],
|
||||
height=monitor["height"],
|
||||
display_index=self.display_index,
|
||||
|
||||
@@ -5,6 +5,14 @@ import sys
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import cv2
|
||||
|
||||
_HAS_CV2 = True
|
||||
except ImportError:
|
||||
_HAS_CV2 = False
|
||||
|
||||
from ledgrab.core.capture_engines.base import (
|
||||
CaptureEngine,
|
||||
@@ -16,6 +24,10 @@ from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# 3-slot rotating output buffer pool: ensures the consumer always has a stable
|
||||
# RGB array reference (the underlying WGC native buffer is reused per frame).
|
||||
_RGB_POOL_SIZE = 3
|
||||
|
||||
|
||||
class WGCCaptureStream(CaptureStream):
|
||||
"""WGC capture stream for a specific display."""
|
||||
@@ -29,6 +41,11 @@ class WGCCaptureStream(CaptureStream):
|
||||
self._frame_event = threading.Event()
|
||||
self._closed_event = threading.Event()
|
||||
self._frame_lock = threading.Lock()
|
||||
# Pre-allocated RGB destination buffers (rotated to keep prior frames
|
||||
# stable for any consumer still reading the previous reference).
|
||||
self._rgb_pool: list = [None] * _RGB_POOL_SIZE
|
||||
self._rgb_idx: int = 0
|
||||
self._rgb_shape: tuple = (0, 0)
|
||||
|
||||
def initialize(self) -> None:
|
||||
if self._wgc is None:
|
||||
@@ -66,10 +83,33 @@ class WGCCaptureStream(CaptureStream):
|
||||
width = frame.width
|
||||
height = frame.height
|
||||
|
||||
# WGC provides BGRA format, convert to RGB
|
||||
# Fancy indexing creates a new contiguous array — no .copy() needed
|
||||
# WGC provides BGRA. ``frame_buffer`` is a view over the
|
||||
# native side's reusable buffer — must copy out before
|
||||
# returning. Use a 3-slot rotating pool of pre-allocated
|
||||
# RGB buffers + cv2.cvtColor (SIMD) instead of numpy fancy
|
||||
# indexing. Fancy indexing would allocate ~width*height*3
|
||||
# bytes per frame (≈480 MB/s at 1080p60); the pool allocates
|
||||
# 3 buffers total and reuses them.
|
||||
frame_array = frame_buffer.reshape((height, width, 4))
|
||||
frame_rgb = frame_array[:, :, [2, 1, 0]]
|
||||
|
||||
if self._rgb_shape != (height, width):
|
||||
for i in range(_RGB_POOL_SIZE):
|
||||
self._rgb_pool[i] = np.empty((height, width, 3), dtype=np.uint8)
|
||||
self._rgb_shape = (height, width)
|
||||
|
||||
dst = self._rgb_pool[self._rgb_idx]
|
||||
self._rgb_idx = (self._rgb_idx + 1) % _RGB_POOL_SIZE
|
||||
|
||||
if _HAS_CV2:
|
||||
cv2.cvtColor(frame_array, cv2.COLOR_BGRA2RGB, dst=dst)
|
||||
frame_rgb = dst
|
||||
else:
|
||||
# Fallback: per-channel copy is still 2× faster than
|
||||
# fancy-index allocation because it writes in-place.
|
||||
dst[..., 0] = frame_array[..., 2]
|
||||
dst[..., 1] = frame_array[..., 1]
|
||||
dst[..., 2] = frame_array[..., 0]
|
||||
frame_rgb = dst
|
||||
|
||||
with self._frame_lock:
|
||||
self._latest_frame = frame_rgb
|
||||
@@ -153,8 +193,10 @@ class WGCCaptureStream(CaptureStream):
|
||||
self._cleanup_internal()
|
||||
self._initialized = False
|
||||
|
||||
# Force garbage collection to release COM objects
|
||||
gc.collect()
|
||||
# Gen-0 collect is enough to release recently-allocated COM
|
||||
# references and avoids the multi-hundred-ms full-heap pause
|
||||
# ``gc.collect()`` would cause on a heap full of frame ndarrays.
|
||||
gc.collect(0)
|
||||
logger.info(f"WGC capture stream cleaned up (display={self.display_index})")
|
||||
|
||||
def capture_frame(self) -> Optional[ScreenCapture]:
|
||||
@@ -173,7 +215,10 @@ class WGCCaptureStream(CaptureStream):
|
||||
self._frame_event.clear()
|
||||
|
||||
logger.debug(
|
||||
f"WGC captured display {self.display_index}: " f"{frame.shape[1]}x{frame.shape[0]}"
|
||||
"WGC captured frame",
|
||||
display=self.display_index,
|
||||
w=frame.shape[1],
|
||||
h=frame.shape[0],
|
||||
)
|
||||
|
||||
return ScreenCapture(
|
||||
|
||||
@@ -24,6 +24,7 @@ import numpy as np
|
||||
from ledgrab.core.devices.ble_protocols import BLEProtocol, get_protocol
|
||||
from ledgrab.core.devices.ble_transport import make_transport
|
||||
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from ledgrab.core.devices.pixel_reduce import average_color as _average_color
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -63,25 +64,6 @@ def _strip_ble_scheme(url: str) -> str:
|
||||
return url.strip("/")
|
||||
|
||||
|
||||
def _average_color(pixels: Union[List[Tuple[int, int, int]], np.ndarray]) -> Tuple[int, int, int]:
|
||||
"""Reduce an N-pixel strip to one average RGB."""
|
||||
if isinstance(pixels, np.ndarray):
|
||||
if pixels.size == 0:
|
||||
return (0, 0, 0)
|
||||
arr = pixels.reshape(-1, 3) if pixels.ndim > 1 else pixels[:3].reshape(1, 3)
|
||||
mean = arr.mean(axis=0)
|
||||
return int(mean[0]), int(mean[1]), int(mean[2])
|
||||
if not pixels:
|
||||
return (0, 0, 0)
|
||||
total_r = total_g = total_b = 0
|
||||
for r, g, b in pixels:
|
||||
total_r += r
|
||||
total_g += g
|
||||
total_b += b
|
||||
n = len(pixels)
|
||||
return total_r // n, total_g // n, total_b // n
|
||||
|
||||
|
||||
class BLEClient(LEDClient):
|
||||
"""LED client for BLE controllers speaking one of the registered protocols.
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Standalone DDP (Distributed Display Protocol) LEDClient.
|
||||
|
||||
Wraps the low-level ``DDPClient`` transport from ``ddp_client.py`` to expose
|
||||
DDP as a first-class device type. Any receiver that speaks DDP — Pixelblaze,
|
||||
ESPixelStick, xLights/Falcon endpoints, generic DDP firmware — can be driven
|
||||
through this client without WLED in the path.
|
||||
|
||||
URL scheme: ``ddp://host[:port]`` or a bare ``host[:port]``. Default port 4048.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.devices.ddp_client import DDPClient
|
||||
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DEFAULT_DDP_PORT = 4048
|
||||
DEFAULT_DESTINATION_ID = 0x01 # 1 = display
|
||||
DEFAULT_COLOR_ORDER = 1 # 1 = RGB (no reorder)
|
||||
|
||||
|
||||
def parse_ddp_url(url: str) -> Tuple[str, int]:
|
||||
"""Parse a DDP URL into ``(host, port)``.
|
||||
|
||||
Accepted forms:
|
||||
``ddp://192.168.1.50`` → ("192.168.1.50", 4048)
|
||||
``ddp://192.168.1.50:4048`` → ("192.168.1.50", 4048)
|
||||
``192.168.1.50`` → ("192.168.1.50", 4048)
|
||||
``192.168.1.50:4048`` → ("192.168.1.50", 4048)
|
||||
``ddp://[fe80::1]:4048`` → ("fe80::1", 4048)
|
||||
"""
|
||||
if not url:
|
||||
raise ValueError("DDP URL is empty")
|
||||
raw = url.strip()
|
||||
if "://" in raw:
|
||||
parsed = urlparse(raw)
|
||||
host = parsed.hostname or ""
|
||||
port = parsed.port or DEFAULT_DDP_PORT
|
||||
else:
|
||||
# Bare ``host`` or ``host:port`` — wrap into a URL so urlparse handles IPv6.
|
||||
parsed = urlparse(f"ddp://{raw}")
|
||||
host = parsed.hostname or ""
|
||||
port = parsed.port or DEFAULT_DDP_PORT
|
||||
if not host:
|
||||
raise ValueError(f"DDP URL has no host: {url!r}")
|
||||
return host, port
|
||||
|
||||
|
||||
class DDPLEDClient(LEDClient):
|
||||
"""LEDClient for generic DDP receivers.
|
||||
|
||||
Designed for the streaming hot loop: ``send_pixels_fast`` is a synchronous
|
||||
fire-and-forget UDP push that delegates to the pre-allocated DDP transport.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
led_count: int = 0,
|
||||
*,
|
||||
rgbw: bool = False,
|
||||
port: Optional[int] = None,
|
||||
destination_id: int = DEFAULT_DESTINATION_ID,
|
||||
color_order: int = DEFAULT_COLOR_ORDER,
|
||||
):
|
||||
parsed_host, parsed_port = parse_ddp_url(url)
|
||||
self._host = parsed_host
|
||||
self._port = port or parsed_port
|
||||
self._led_count = led_count
|
||||
self._rgbw = rgbw
|
||||
self._destination_id = destination_id & 0xFF
|
||||
self._color_order = color_order
|
||||
self._ddp: Optional[DDPClient] = None
|
||||
self._connected = False
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return self._port
|
||||
|
||||
@property
|
||||
def device_led_count(self) -> Optional[int]:
|
||||
return self._led_count or None
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._ddp is not None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
if self._connected and self._ddp is not None:
|
||||
return True
|
||||
ddp = DDPClient(self._host, port=self._port, rgbw=self._rgbw)
|
||||
await ddp.connect()
|
||||
# Use the BusConfig hook to encode color order across the full strip
|
||||
# if the user picked something other than RGB.
|
||||
if self._color_order != DEFAULT_COLOR_ORDER and self._led_count > 0:
|
||||
from ledgrab.core.devices.ddp_client import BusConfig
|
||||
|
||||
ddp.set_buses(
|
||||
[
|
||||
BusConfig(
|
||||
start=0,
|
||||
length=self._led_count,
|
||||
color_order=self._color_order,
|
||||
)
|
||||
]
|
||||
)
|
||||
self._ddp = ddp
|
||||
self._connected = True
|
||||
logger.info(
|
||||
"DDPLEDClient connected to %s:%d (led_count=%d, rgbw=%s, order=%d)",
|
||||
self._host,
|
||||
self._port,
|
||||
self._led_count,
|
||||
self._rgbw,
|
||||
self._color_order,
|
||||
)
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._ddp is not None:
|
||||
try:
|
||||
await self._ddp.close()
|
||||
finally:
|
||||
self._ddp = None
|
||||
self._connected = False
|
||||
|
||||
@property
|
||||
def supports_fast_send(self) -> bool:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _apply_brightness(pixels: np.ndarray, brightness: int) -> np.ndarray:
|
||||
if brightness >= 255:
|
||||
return pixels
|
||||
if brightness <= 0:
|
||||
return np.zeros_like(pixels)
|
||||
# uint16 scratch avoids overflow; integer divide keeps everything in uint8.
|
||||
return ((pixels.astype(np.uint16) * brightness) // 255).astype(np.uint8)
|
||||
|
||||
def _as_numpy(self, pixels: Union[List[Tuple[int, int, int]], np.ndarray]) -> np.ndarray:
|
||||
if isinstance(pixels, np.ndarray):
|
||||
arr = pixels
|
||||
else:
|
||||
arr = np.asarray(pixels, dtype=np.uint8)
|
||||
if arr.dtype != np.uint8:
|
||||
arr = arr.astype(np.uint8)
|
||||
if arr.ndim == 1 and arr.shape[0] % 3 == 0:
|
||||
arr = arr.reshape(-1, 3)
|
||||
return arr
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("DDPLEDClient not connected")
|
||||
arr = self._apply_brightness(self._as_numpy(pixels), brightness)
|
||||
assert self._ddp is not None
|
||||
self._ddp.send_pixels_numpy(arr)
|
||||
return True
|
||||
|
||||
def send_pixels_fast(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> None:
|
||||
if not self.is_connected or self._ddp is None:
|
||||
raise RuntimeError("DDPLEDClient not connected")
|
||||
arr = self._apply_brightness(self._as_numpy(pixels), brightness)
|
||||
self._ddp.send_pixels_numpy(arr)
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""DDP is connectionless UDP — health = host resolves + port reachable.
|
||||
|
||||
We don't get an ACK back from DDP receivers, so this is a best-effort
|
||||
probe: resolve the host (cheap, async) and report online if it succeeds.
|
||||
Anything more would require sending a frame and waiting for side effects
|
||||
we can't observe.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
host, _port = parse_ddp_url(url)
|
||||
except ValueError as exc:
|
||||
return DeviceHealth(online=False, last_checked=now, error=str(exc))
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
start = loop.time()
|
||||
await loop.getaddrinfo(host, None, type=socket.SOCK_DGRAM)
|
||||
latency_ms = (loop.time() - start) * 1000.0
|
||||
except (socket.gaierror, OSError) as exc:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
last_checked=now,
|
||||
error=f"DNS resolution failed for {host}: {exc}",
|
||||
)
|
||||
return DeviceHealth(
|
||||
online=True,
|
||||
latency_ms=latency_ms,
|
||||
last_checked=now,
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""DDP device provider — standalone UDP target for any DDP-speaking receiver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from ledgrab.core.devices.ddp_led_client import DDPLEDClient, parse_ddp_url
|
||||
from ledgrab.utils.net_classify import validate_lan_host
|
||||
from ledgrab.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
ProviderDeps,
|
||||
)
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ledgrab.core.devices.device_config import DDPConfig
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DDPDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for generic DDP receivers (Pixelblaze, ESPixelStick, Falcon, …).
|
||||
|
||||
DDP has no native discovery protocol — callers must supply a manual IP/host.
|
||||
LED count is also user-supplied since DDP receivers don't expose a metadata
|
||||
channel back to the sender.
|
||||
"""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "ddp"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
# No power_control / brightness_control: DDP receivers don't define a
|
||||
# standard reply channel, so we cannot read back state. Software
|
||||
# brightness is still applied client-side before the frame is sent.
|
||||
return {"manual_led_count", "health_check"}
|
||||
|
||||
def create_client(self, config: "DDPConfig", *, deps: ProviderDeps) -> LEDClient:
|
||||
return DDPLEDClient(
|
||||
config.device_url,
|
||||
led_count=config.led_count,
|
||||
rgbw=config.rgbw,
|
||||
port=config.ddp_port or None,
|
||||
destination_id=config.ddp_destination_id,
|
||||
color_order=config.ddp_color_order,
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return await DDPLEDClient.check_health(url, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
"""Validate URL parses cleanly. DDP receivers don't report LED count."""
|
||||
try:
|
||||
host, port = parse_ddp_url(url)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid DDP URL: {exc}") from exc
|
||||
validate_lan_host(host)
|
||||
logger.info("DDP device URL validated: host=%s port=%d", host, port)
|
||||
return {}
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
"""DDP has no native discovery — returns empty list."""
|
||||
return []
|
||||
@@ -25,6 +25,21 @@ class WLEDConfig(BaseDeviceConfig):
|
||||
use_ddp: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DDPConfig(BaseDeviceConfig):
|
||||
"""Standalone DDP receiver (Pixelblaze, ESPixelStick, Falcon, …).
|
||||
|
||||
``ddp_port`` of 0 means "use the protocol default" (4048). ``ddp_color_order``
|
||||
follows the WLED enum (0=GRB, 1=RGB, 2=BRG, 3=RBG, 4=BGR, 5=GBR). Most
|
||||
non-WLED DDP receivers expect raw RGB (1).
|
||||
"""
|
||||
|
||||
device_type: Literal["ddp"] = "ddp"
|
||||
ddp_port: int = 0
|
||||
ddp_destination_id: int = 1
|
||||
ddp_color_order: int = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdalightConfig(BaseDeviceConfig):
|
||||
device_type: Literal["adalight"] = "adalight"
|
||||
@@ -61,6 +76,80 @@ class HueConfig(BaseDeviceConfig):
|
||||
hue_entertainment_group_id: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class YeelightConfig(BaseDeviceConfig):
|
||||
"""Yeelight (Xiaomi) LAN bulb / lightstrip.
|
||||
|
||||
``yeelight_min_interval_ms`` rate-limits outbound commands client-side
|
||||
so the bulb's per-second cap isn't exceeded. Default 500 ms ≈ 2 Hz.
|
||||
"""
|
||||
|
||||
device_type: Literal["yeelight"] = "yeelight"
|
||||
yeelight_min_interval_ms: int = 500
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WiZConfig(BaseDeviceConfig):
|
||||
"""WiZ Connected (Philips budget-tier) UDP LAN bulb.
|
||||
|
||||
``wiz_min_interval_ms`` is a client-side rate gate. WiZ tolerates much
|
||||
higher rates than Yeelight (UDP, no ack) so the default is 50 ms ≈ 20 Hz.
|
||||
"""
|
||||
|
||||
device_type: Literal["wiz"] = "wiz"
|
||||
wiz_min_interval_ms: int = 50
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LIFXConfig(BaseDeviceConfig):
|
||||
"""LIFX LAN bulb / lightstrip.
|
||||
|
||||
LIFX recommends ≤20 commands/sec per device. ``lifx_min_interval_ms``
|
||||
defaults to 50 ms so we stay just under that ceiling.
|
||||
"""
|
||||
|
||||
device_type: Literal["lifx"] = "lifx"
|
||||
lifx_min_interval_ms: int = 50
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GoveeConfig(BaseDeviceConfig):
|
||||
"""Govee Wi-Fi bulb / ambient kit reachable via the LAN API.
|
||||
|
||||
Each device needs "LAN Control" toggled ON in the Govee Home app before
|
||||
it answers discovery or commands. UDP fire-and-forget tolerates ~20 Hz.
|
||||
"""
|
||||
|
||||
device_type: Literal["govee"] = "govee"
|
||||
govee_min_interval_ms: int = 50
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OPCConfig(BaseDeviceConfig):
|
||||
"""Open Pixel Control receiver (Fadecandy, OPC bridges, hobbyist drivers).
|
||||
|
||||
``opc_channel`` of 0 broadcasts to every channel on the OPC server;
|
||||
1-255 addresses a specific output on multi-channel servers.
|
||||
"""
|
||||
|
||||
device_type: Literal["opc"] = "opc"
|
||||
opc_channel: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NanoleafConfig(BaseDeviceConfig):
|
||||
"""Nanoleaf controller (Light Panels / Canvas / Shapes / Lines / Elements).
|
||||
|
||||
``nanoleaf_token`` is the long-lived auth token returned by the pairing
|
||||
handshake. Without it the controller rejects every state-mutating call,
|
||||
so device creation should be preceded by a successful pairing flow.
|
||||
"""
|
||||
|
||||
device_type: Literal["nanoleaf"] = "nanoleaf"
|
||||
nanoleaf_token: str = ""
|
||||
nanoleaf_min_interval_ms: int = 100
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SPIConfig(BaseDeviceConfig):
|
||||
device_type: Literal["spi"] = "spi"
|
||||
@@ -115,6 +204,7 @@ class DemoConfig(BaseDeviceConfig):
|
||||
@dataclass(frozen=True)
|
||||
class MQTTConfig(BaseDeviceConfig):
|
||||
device_type: Literal["mqtt"] = "mqtt"
|
||||
mqtt_source_id: str = "" # references an MQTTSource (multi-broker)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -129,6 +219,13 @@ class USBHIDConfig(BaseDeviceConfig):
|
||||
|
||||
DeviceConfig = Union[
|
||||
WLEDConfig,
|
||||
DDPConfig,
|
||||
YeelightConfig,
|
||||
WiZConfig,
|
||||
LIFXConfig,
|
||||
GoveeConfig,
|
||||
OPCConfig,
|
||||
NanoleafConfig,
|
||||
AdalightConfig,
|
||||
AmbiLEDConfig,
|
||||
DMXConfig,
|
||||
|
||||
@@ -85,6 +85,10 @@ class DiscoveryWatcher:
|
||||
self._wled_seen: Dict[str, _DiscoveredEntry] = {}
|
||||
# device-path -> entry. Only the serial poller mutates this.
|
||||
self._serial_seen: Dict[str, _DiscoveredEntry] = {}
|
||||
# Strong references for fire-and-forget resolve tasks — without
|
||||
# these, Python 3.11+ may GC the task mid-resolve and silently lose
|
||||
# discovery events. Tasks remove themselves on completion.
|
||||
self._resolve_tasks: "set[asyncio.Task]" = set()
|
||||
|
||||
# --- lifecycle --------------------------------------------------------
|
||||
|
||||
@@ -155,12 +159,23 @@ class DiscoveryWatcher:
|
||||
if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated):
|
||||
# Resolve in a task — async_request blocks the handler if awaited
|
||||
# synchronously and we don't want to stall mDNS dispatch.
|
||||
asyncio.create_task(self._resolve_wled(service_type, name))
|
||||
task = asyncio.create_task(self._resolve_wled(service_type, name))
|
||||
self._resolve_tasks.add(task)
|
||||
task.add_done_callback(self._on_resolve_done)
|
||||
elif state_change == ServiceStateChange.Removed:
|
||||
entry = self._wled_seen.pop(name, None)
|
||||
if entry is not None and not self._is_configured(entry.url):
|
||||
self._emit("device_lost", entry)
|
||||
|
||||
def _on_resolve_done(self, task: "asyncio.Task") -> None:
|
||||
"""Release the strong task reference and log any resolve failure."""
|
||||
self._resolve_tasks.discard(task)
|
||||
if task.cancelled():
|
||||
return
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
logger.debug("Discovery watcher: resolve task raised: %s", exc)
|
||||
|
||||
async def _resolve_wled(self, service_type: str, name: str) -> None:
|
||||
if self._aiozc is None:
|
||||
return
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Govee LAN API LED client.
|
||||
|
||||
Govee opened a local LAN API in 2023 for its Wi-Fi smart bulbs and
|
||||
ambient-light kits. Discovery is multicast on ``239.255.255.250:4001``;
|
||||
control commands go unicast to the bulb's port ``4003``; the bulb sends
|
||||
responses on port ``4002`` (which we don't listen on for ambient streaming).
|
||||
|
||||
Prerequisite for every device: the user must toggle "LAN Control" ON in the
|
||||
Govee Home app (Device → ⚙ → LAN Control). Devices with LAN Control disabled
|
||||
do not respond to discovery or commands. The UI hint copy reminds the user.
|
||||
|
||||
URL scheme: ``govee://<host>`` or bare ``<host>``. Port 4003 is fixed.
|
||||
|
||||
Reference: https://app-h5.govee.com/user-manual/wlan-guide
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from ledgrab.core.devices.pixel_reduce import average_color as _average_color
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
GOVEE_DISCOVERY_PORT = 4001
|
||||
GOVEE_RESPONSE_PORT = 4002
|
||||
GOVEE_CONTROL_PORT = 4003
|
||||
GOVEE_MULTICAST_GROUP = "239.255.255.250"
|
||||
DEFAULT_MIN_INTERVAL_S = 0.05 # ~20 Hz; UDP fire-and-forget, no ack
|
||||
|
||||
_DISCOVERY_REQUEST = json.dumps(
|
||||
{"msg": {"cmd": "scan", "data": {"account_topic": "reserve"}}}
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
# Govee mandates listening on port 4002 for scan replies; the OS only lets
|
||||
# one process / socket own it at a time. Serialize concurrent discover
|
||||
# calls (e.g. two browser tabs hitting /discover at once) so the second
|
||||
# caller waits its turn instead of getting an empty result.
|
||||
_DISCOVERY_LOCK: asyncio.Lock | None = None
|
||||
|
||||
|
||||
def _discovery_lock() -> asyncio.Lock:
|
||||
"""Return a module-level asyncio.Lock, created lazily.
|
||||
|
||||
Can't construct at import-time because asyncio.Lock binds to the
|
||||
running event loop and module import may precede loop creation.
|
||||
"""
|
||||
global _DISCOVERY_LOCK
|
||||
if _DISCOVERY_LOCK is None:
|
||||
_DISCOVERY_LOCK = asyncio.Lock()
|
||||
return _DISCOVERY_LOCK
|
||||
|
||||
|
||||
def parse_govee_url(url: str) -> str:
|
||||
"""Pull the host out of ``govee://host`` or bare ``host``."""
|
||||
if not url:
|
||||
raise ValueError("Govee URL is empty")
|
||||
raw = url.strip()
|
||||
if "://" in raw:
|
||||
parsed = urlparse(raw)
|
||||
host = parsed.hostname or ""
|
||||
else:
|
||||
parsed = urlparse(f"govee://{raw}")
|
||||
host = parsed.hostname or ""
|
||||
if not host:
|
||||
raise ValueError(f"Govee URL has no host: {url!r}")
|
||||
return host
|
||||
|
||||
|
||||
class _GoveeProtocol(asyncio.DatagramProtocol):
|
||||
"""Write-only datagram protocol. Bulb replies (on 4002) are not collected here."""
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
pass
|
||||
|
||||
def error_received(self, exc):
|
||||
logger.debug("Govee UDP error: %s", exc)
|
||||
|
||||
|
||||
class GoveeClient(LEDClient):
|
||||
"""LEDClient for a single Govee LAN-enabled bulb / ambient kit."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
led_count: int = 1,
|
||||
*,
|
||||
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
|
||||
):
|
||||
self._host = parse_govee_url(url)
|
||||
self._port = GOVEE_CONTROL_PORT
|
||||
self._led_count = led_count
|
||||
self._min_interval_s = max(0.0, min_interval_s)
|
||||
self._transport: Optional[asyncio.DatagramTransport] = None
|
||||
self._protocol: Optional[_GoveeProtocol] = None
|
||||
self._connected = False
|
||||
self._next_tx_at: float = 0.0
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return self._port
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._transport is not None
|
||||
|
||||
@property
|
||||
def device_led_count(self) -> Optional[int]:
|
||||
return self._led_count or None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
if self._connected and self._transport is not None:
|
||||
return True
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
transport, protocol = await loop.create_datagram_endpoint(
|
||||
_GoveeProtocol, remote_addr=(self._host, self._port)
|
||||
)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Failed to open UDP to Govee at {self._host}: {exc}") from exc
|
||||
self._transport = transport
|
||||
self._protocol = protocol # type: ignore[assignment]
|
||||
self._connected = True
|
||||
logger.info("GoveeClient connected to %s:%d", self._host, self._port)
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._transport is not None:
|
||||
try:
|
||||
self._transport.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._transport = None
|
||||
self._protocol = None
|
||||
self._connected = False
|
||||
|
||||
def _send_json(self, payload: dict) -> None:
|
||||
assert self._transport is not None
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
self._transport.sendto(raw)
|
||||
|
||||
@staticmethod
|
||||
def _build_color_payload(r: int, g: int, b: int) -> dict:
|
||||
"""Govee colorwc command. ``colorTemInKelvin=0`` selects pure RGB."""
|
||||
return {
|
||||
"msg": {
|
||||
"cmd": "colorwc",
|
||||
"data": {
|
||||
"color": {"r": r & 0xFF, "g": g & 0xFF, "b": b & 0xFF},
|
||||
"colorTemInKelvin": 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_brightness_payload(value_0_100: int) -> dict:
|
||||
return {
|
||||
"msg": {
|
||||
"cmd": "brightness",
|
||||
"data": {"value": max(1, min(100, value_0_100))},
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_power_payload(on: bool) -> dict:
|
||||
return {"msg": {"cmd": "turn", "data": {"value": 1 if on else 0}}}
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
"""Average the strip → colorwc with the resulting RGB."""
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("GoveeClient not connected")
|
||||
now = time.monotonic()
|
||||
if now < self._next_tx_at:
|
||||
return True
|
||||
r, g, b = _average_color(pixels)
|
||||
if brightness < 255:
|
||||
scale = max(0, min(255, brightness)) / 255.0
|
||||
r = int(r * scale)
|
||||
g = int(g * scale)
|
||||
b = int(b * scale)
|
||||
self._send_json(self._build_color_payload(r, g, b))
|
||||
self._next_tx_at = now + self._min_interval_s
|
||||
return True
|
||||
|
||||
def send_pixels_fast(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> None:
|
||||
"""Synchronous variant for the hot loop."""
|
||||
if not self.is_connected or self._transport is None:
|
||||
raise RuntimeError("GoveeClient not connected")
|
||||
now = time.monotonic()
|
||||
if now < self._next_tx_at:
|
||||
return
|
||||
r, g, b = _average_color(pixels)
|
||||
if brightness < 255:
|
||||
scale = max(0, min(255, brightness)) / 255.0
|
||||
r = int(r * scale)
|
||||
g = int(g * scale)
|
||||
b = int(b * scale)
|
||||
self._send_json(self._build_color_payload(r, g, b))
|
||||
self._next_tx_at = now + self._min_interval_s
|
||||
|
||||
@property
|
||||
def supports_fast_send(self) -> bool:
|
||||
return True
|
||||
|
||||
async def set_color(self, r: int, g: int, b: int) -> None:
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("GoveeClient not connected")
|
||||
self._send_json(self._build_color_payload(r, g, b))
|
||||
|
||||
async def set_brightness(self, brightness_0_100: int) -> None:
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("GoveeClient not connected")
|
||||
self._send_json(self._build_brightness_payload(brightness_0_100))
|
||||
|
||||
async def set_power(self, on: bool) -> None:
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("GoveeClient not connected")
|
||||
self._send_json(self._build_power_payload(on))
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""Send devStatus and wait briefly for a reply on port 4002.
|
||||
|
||||
Govee bulbs send responses to whatever port the request came from
|
||||
when using ``connected`` UDP, so we bind to a random ephemeral port
|
||||
and accept any reply. Health is best-effort — a silent bulb may
|
||||
still be online (it just hasn't toggled LAN Control on yet).
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
host = parse_govee_url(url)
|
||||
except ValueError as exc:
|
||||
return DeviceHealth(online=False, last_checked=now, error=str(exc))
|
||||
loop = asyncio.get_running_loop()
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setblocking(False)
|
||||
try:
|
||||
sock.bind(("", 0))
|
||||
probe = json.dumps({"msg": {"cmd": "devStatus", "data": {}}}).encode("utf-8")
|
||||
start = loop.time()
|
||||
await loop.sock_sendto(sock, probe, (host, GOVEE_CONTROL_PORT))
|
||||
try:
|
||||
await asyncio.wait_for(loop.sock_recv(sock, 4096), timeout=1.5)
|
||||
except asyncio.TimeoutError:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
last_checked=now,
|
||||
error=(
|
||||
f"No Govee reply from {host} within 1.5s — is "
|
||||
"LAN Control enabled in the Govee Home app?"
|
||||
),
|
||||
)
|
||||
latency_ms = (loop.time() - start) * 1000.0
|
||||
return DeviceHealth(online=True, latency_ms=latency_ms, last_checked=now)
|
||||
except OSError as exc:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
last_checked=now,
|
||||
error=f"Govee probe failed for {host}: {exc}",
|
||||
)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Multicast discovery
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _parse_scan_reply(raw: bytes) -> Optional[dict]:
|
||||
"""Parse a Govee scan reply into a flat metadata dict.
|
||||
|
||||
Govee sends ``{"msg": {"cmd": "scan", "data": {"ip": ..., "device": ...,
|
||||
"sku": ..., "wifiVersionSoft": ..., ...}}}``. Returns the inner ``data``
|
||||
dict, or ``None`` for malformed packets.
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8", errors="replace"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
msg = payload.get("msg")
|
||||
if not isinstance(msg, dict):
|
||||
return None
|
||||
if msg.get("cmd") != "scan":
|
||||
return None
|
||||
data = msg.get("data")
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
async def discover_govee_devices(timeout: float = 2.0) -> List[dict]:
|
||||
"""Multicast a scan request and collect Govee scan replies.
|
||||
|
||||
Returns a list of ``{"ip": ..., "device": ..., "sku": ..., "version": ...}``
|
||||
dicts. Multicast / receive failures (no network, firewall, no LAN-enabled
|
||||
bulbs) yield an empty list rather than raising.
|
||||
|
||||
Concurrent invocations are serialized via a module-level
|
||||
``asyncio.Lock`` so two callers don't race on the protocol-mandated
|
||||
response port 4002.
|
||||
"""
|
||||
async with _discovery_lock():
|
||||
return await _discover_govee_devices_locked(timeout)
|
||||
|
||||
|
||||
async def _discover_govee_devices_locked(timeout: float) -> List[dict]:
|
||||
loop = asyncio.get_running_loop()
|
||||
# We bind a separate socket to the response port (4002). The
|
||||
# _DISCOVERY_LOCK serializes our own scans; the bind can still fail
|
||||
# if a non-LedGrab Govee tool on the same host owns 4002.
|
||||
recv_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
recv_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
recv_sock.setblocking(False)
|
||||
send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
send_sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
||||
send_sock.setblocking(False)
|
||||
try:
|
||||
try:
|
||||
recv_sock.bind(("", GOVEE_RESPONSE_PORT))
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Govee discovery: cannot bind port %d (%s). Another Govee "
|
||||
"tool on this host may own the port; close it and retry.",
|
||||
GOVEE_RESPONSE_PORT,
|
||||
exc,
|
||||
)
|
||||
return []
|
||||
send_sock.bind(("", 0))
|
||||
await loop.sock_sendto(
|
||||
send_sock, _DISCOVERY_REQUEST, (GOVEE_MULTICAST_GROUP, GOVEE_DISCOVERY_PORT)
|
||||
)
|
||||
results: list[dict] = []
|
||||
seen_ips: set[str] = set()
|
||||
deadline = loop.time() + timeout
|
||||
while True:
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
raw, addr = await asyncio.wait_for(
|
||||
loop.sock_recvfrom(recv_sock, 4096),
|
||||
timeout=remaining,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
data = _parse_scan_reply(raw)
|
||||
if not data:
|
||||
continue
|
||||
ip = data.get("ip") or addr[0]
|
||||
if not ip or ip in seen_ips:
|
||||
continue
|
||||
seen_ips.add(ip)
|
||||
results.append(
|
||||
{
|
||||
"ip": ip,
|
||||
"device": data.get("device", ""),
|
||||
"sku": data.get("sku", ""),
|
||||
"version": data.get("wifiVersionSoft", "") or data.get("bleVersionSoft", ""),
|
||||
}
|
||||
)
|
||||
return results
|
||||
finally:
|
||||
recv_sock.close()
|
||||
send_sock.close()
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Govee LAN device provider — LAN-discoverable Govee Wi-Fi bulbs and kits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from ledgrab.core.devices.govee_client import (
|
||||
GoveeClient,
|
||||
discover_govee_devices,
|
||||
parse_govee_url,
|
||||
)
|
||||
from ledgrab.utils.net_classify import validate_lan_host
|
||||
from ledgrab.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
ProviderDeps,
|
||||
)
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ledgrab.core.devices.device_config import GoveeConfig
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GoveeDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for Govee LAN-enabled Wi-Fi smart bulbs and ambient kits.
|
||||
|
||||
Single-pixel adapter (averaging shape). Note that **per-device LAN
|
||||
Control toggle must be enabled in the Govee Home app** before the bulb
|
||||
will respond to discovery or commands — the UI hint copy reminds users.
|
||||
"""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "govee"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
return {
|
||||
"manual_led_count",
|
||||
"power_control",
|
||||
"brightness_control",
|
||||
"static_color",
|
||||
"health_check",
|
||||
"single_pixel",
|
||||
}
|
||||
|
||||
def create_client(self, config: "GoveeConfig", *, deps: ProviderDeps) -> LEDClient:
|
||||
return GoveeClient(
|
||||
config.device_url,
|
||||
led_count=config.led_count,
|
||||
min_interval_s=max(0.0, config.govee_min_interval_ms / 1000.0),
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return await GoveeClient.check_health(url, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
try:
|
||||
host = parse_govee_url(url)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid Govee URL: {exc}") from exc
|
||||
validate_lan_host(host)
|
||||
logger.info("Govee device URL validated: host=%s", host)
|
||||
return {}
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
try:
|
||||
bulbs = await discover_govee_devices(timeout=min(timeout, 5.0))
|
||||
except (OSError, RuntimeError) as exc:
|
||||
logger.warning("Govee discovery failed: %s", exc)
|
||||
return []
|
||||
|
||||
results: List[DiscoveredDevice] = []
|
||||
for bulb in bulbs:
|
||||
ip = bulb.get("ip", "")
|
||||
if not ip:
|
||||
continue
|
||||
url = f"govee://{ip}"
|
||||
sku = bulb.get("sku") or "Govee"
|
||||
mac_like = bulb.get("device", "")
|
||||
results.append(
|
||||
DiscoveredDevice(
|
||||
name=f"Govee {sku}".strip(),
|
||||
url=url,
|
||||
device_type="govee",
|
||||
ip=ip,
|
||||
mac=mac_like,
|
||||
led_count=None,
|
||||
version=bulb.get("version") or None,
|
||||
)
|
||||
)
|
||||
logger.info("Govee multicast scan found %d device(s)", len(results))
|
||||
return results
|
||||
@@ -17,6 +17,7 @@ class ProviderDeps:
|
||||
"""Runtime dependencies injected into every provider.create_client() call."""
|
||||
|
||||
device_store: Optional["DeviceStore"] = None
|
||||
mqtt_manager: Optional[object] = None # MQTTManager (avoid circular import)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -36,6 +37,15 @@ class DeviceHealth:
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class PairingNotReady(Exception):
|
||||
"""Raised by ``pair_device`` when the user hasn't performed the physical
|
||||
pairing action yet (or it timed out before the device acknowledged).
|
||||
|
||||
Distinct from generic exceptions so the route handler can return a 409
|
||||
instead of a 500 — the UI shows a retry prompt rather than a hard error.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveredDevice:
|
||||
"""A device found via network discovery."""
|
||||
@@ -215,6 +225,30 @@ class LEDDeviceProvider(ABC):
|
||||
"""
|
||||
return []
|
||||
|
||||
async def pair_device(self, url: str) -> Dict[str, object]:
|
||||
"""Run a pairing handshake against the device at ``url``.
|
||||
|
||||
Implementations expect the user to have just performed the device's
|
||||
physical pairing action (Nanoleaf: hold power 5s; Hue: press the
|
||||
link button; Twinkly: enter Network setup mode) before calling.
|
||||
|
||||
Returns:
|
||||
A dict of provider-specific fields that the caller should merge
|
||||
into the subsequent ``create_device`` payload — e.g.
|
||||
``{"nanoleaf_token": "abc..."}`` or ``{"tuya_local_key": "..."}``.
|
||||
|
||||
Raises:
|
||||
PairingNotReady when the user hasn't performed the physical
|
||||
action yet (the caller surfaces this as a retryable 409 so
|
||||
the UI can show "Press the button now, then try again").
|
||||
ValueError on a fundamentally invalid URL or device.
|
||||
|
||||
Default: raises ``NotImplementedError`` so a missing implementation
|
||||
on a ``requires_pairing`` provider fails loud at request time
|
||||
rather than silently returning an empty dict.
|
||||
"""
|
||||
raise NotImplementedError(f"{self.device_type} doesn't support pairing")
|
||||
|
||||
async def get_brightness(self, url: str) -> int:
|
||||
"""Get device brightness (0-255). Override if capabilities include brightness_control."""
|
||||
raise NotImplementedError
|
||||
@@ -301,6 +335,10 @@ def _register_builtin_providers():
|
||||
|
||||
register_provider(AdalightDeviceProvider())
|
||||
|
||||
from ledgrab.core.devices.ddp_provider import DDPDeviceProvider
|
||||
|
||||
register_provider(DDPDeviceProvider())
|
||||
|
||||
from ledgrab.core.devices.ambiled_provider import AmbiLEDDeviceProvider
|
||||
|
||||
register_provider(AmbiLEDDeviceProvider())
|
||||
@@ -333,6 +371,30 @@ def _register_builtin_providers():
|
||||
|
||||
register_provider(HueDeviceProvider())
|
||||
|
||||
from ledgrab.core.devices.yeelight_provider import YeelightDeviceProvider
|
||||
|
||||
register_provider(YeelightDeviceProvider())
|
||||
|
||||
from ledgrab.core.devices.wiz_provider import WiZDeviceProvider
|
||||
|
||||
register_provider(WiZDeviceProvider())
|
||||
|
||||
from ledgrab.core.devices.lifx_provider import LIFXDeviceProvider
|
||||
|
||||
register_provider(LIFXDeviceProvider())
|
||||
|
||||
from ledgrab.core.devices.govee_provider import GoveeDeviceProvider
|
||||
|
||||
register_provider(GoveeDeviceProvider())
|
||||
|
||||
from ledgrab.core.devices.opc_provider import OPCDeviceProvider
|
||||
|
||||
register_provider(OPCDeviceProvider())
|
||||
|
||||
from ledgrab.core.devices.nanoleaf_provider import NanoleafDeviceProvider
|
||||
|
||||
register_provider(NanoleafDeviceProvider())
|
||||
|
||||
# BLE support is optional — only register the provider if the ``bleak``
|
||||
# extra is installed. Importing the provider itself is safe (it doesn't
|
||||
# import bleak at module load), but we still want a clean skip on
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
"""LIFX LAN LED client.
|
||||
|
||||
LIFX bulbs and lightstrips accept a binary UDP protocol on port 56700.
|
||||
Every packet has a 36-byte header (frame + frame-address + protocol-header)
|
||||
followed by a type-specific payload. Colors are HSBK 16-bit per channel.
|
||||
|
||||
URL scheme: ``lifx://<host>[:port]`` or bare ``<host>[:port]``. Default port 56700.
|
||||
|
||||
LIFX bulbs are reachable two ways:
|
||||
* Unicast — set the ``target`` field to the bulb's 48-bit MAC.
|
||||
* Broadcast — set ``target`` to all zeros and ``tagged=1``; all bulbs on
|
||||
the subnet act on the message. We use this for the SetColor hot path
|
||||
so we don't have to learn the MAC of every device a user owns.
|
||||
|
||||
Reference: https://lan.developer.lifx.com/docs/header-description
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from ledgrab.core.devices.pixel_reduce import average_color as _average_color
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
LIFX_PORT = 56700
|
||||
DEFAULT_MIN_INTERVAL_S = 0.05 # ~20 Hz — LIFX rate-limit guidance is 20/sec
|
||||
|
||||
# Message types we care about
|
||||
MSG_GET_SERVICE = 2
|
||||
MSG_STATE_SERVICE = 3
|
||||
MSG_SET_POWER = 21
|
||||
MSG_SET_COLOR = 102
|
||||
|
||||
# Frame field byte 0 of the protocol header: tagged=1, addressable=1, protocol=1024
|
||||
_FRAME_TAGGED = 0x3400
|
||||
_FRAME_UNTAGGED = 0x1400
|
||||
|
||||
_SOURCE_ID = 0x4C474752 # "LGGR" — identifies LedGrab in protocol logs
|
||||
|
||||
|
||||
def parse_lifx_url(url: str) -> Tuple[str, int]:
|
||||
"""Pull ``(host, port)`` from ``lifx://host[:port]`` or bare ``host[:port]``."""
|
||||
if not url:
|
||||
raise ValueError("LIFX URL is empty")
|
||||
raw = url.strip()
|
||||
if "://" in raw:
|
||||
parsed = urlparse(raw)
|
||||
host = parsed.hostname or ""
|
||||
port = parsed.port or LIFX_PORT
|
||||
else:
|
||||
parsed = urlparse(f"lifx://{raw}")
|
||||
host = parsed.hostname or ""
|
||||
port = parsed.port or LIFX_PORT
|
||||
if not host:
|
||||
raise ValueError(f"LIFX URL has no host: {url!r}")
|
||||
return host, port
|
||||
|
||||
|
||||
def rgb_to_hsbk(r: int, g: int, b: int, kelvin: int = 3500) -> Tuple[int, int, int, int]:
|
||||
"""Convert 8-bit RGB to LIFX 16-bit HSBK.
|
||||
|
||||
The ``kelvin`` channel is irrelevant when saturation > 0 (the bulb
|
||||
interprets it as a hint, not a hard temperature), so we leave it at the
|
||||
LIFX default of ~3500 K. Outputs are clamped uint16.
|
||||
"""
|
||||
r_n = max(0, min(255, r)) / 255.0
|
||||
g_n = max(0, min(255, g)) / 255.0
|
||||
b_n = max(0, min(255, b)) / 255.0
|
||||
c_max = max(r_n, g_n, b_n)
|
||||
c_min = min(r_n, g_n, b_n)
|
||||
delta = c_max - c_min
|
||||
|
||||
# Hue (0-360 degrees → 0-65535)
|
||||
if delta == 0:
|
||||
h = 0.0
|
||||
elif c_max == r_n:
|
||||
h = 60.0 * (((g_n - b_n) / delta) % 6)
|
||||
elif c_max == g_n:
|
||||
h = 60.0 * (((b_n - r_n) / delta) + 2)
|
||||
else:
|
||||
h = 60.0 * (((r_n - g_n) / delta) + 4)
|
||||
hue_u16 = int((h / 360.0) * 65535) & 0xFFFF
|
||||
|
||||
# Saturation (0-1 → 0-65535)
|
||||
sat_u16 = 0 if c_max == 0 else int((delta / c_max) * 65535) & 0xFFFF
|
||||
# Brightness (0-1 → 0-65535)
|
||||
bri_u16 = int(c_max * 65535) & 0xFFFF
|
||||
kelvin_u16 = max(2500, min(9000, kelvin)) & 0xFFFF
|
||||
return hue_u16, sat_u16, bri_u16, kelvin_u16
|
||||
|
||||
|
||||
def _build_packet(
|
||||
*,
|
||||
msg_type: int,
|
||||
payload: bytes,
|
||||
target_mac: bytes = b"\x00\x00\x00\x00\x00\x00",
|
||||
sequence: int = 0,
|
||||
res_required: bool = False,
|
||||
ack_required: bool = False,
|
||||
tagged: bool = False,
|
||||
) -> bytes:
|
||||
"""Pack a LIFX packet: 36-byte header + payload.
|
||||
|
||||
See https://lan.developer.lifx.com/docs/header-description for the
|
||||
bit-level field layout. We construct the three sub-headers separately
|
||||
so the magic numbers are scoped to the fields they belong to.
|
||||
"""
|
||||
size = 36 + len(payload)
|
||||
# Frame header (8 bytes): size(2) | origin/tagged/addressable/protocol(2) | source(4)
|
||||
frame_field = _FRAME_TAGGED if tagged else _FRAME_UNTAGGED
|
||||
frame = struct.pack("<HHI", size, frame_field, _SOURCE_ID)
|
||||
# Frame address (16 bytes): target(8) | reserved(6) | res/ack flags(1) | sequence(1)
|
||||
flags = (0x01 if res_required else 0) | (0x02 if ack_required else 0)
|
||||
frame_addr = (
|
||||
target_mac + b"\x00\x00" + b"\x00\x00\x00\x00\x00\x00" + bytes([flags, sequence & 0xFF])
|
||||
)
|
||||
# Protocol header (12 bytes): reserved(8) | type(2) | reserved(2)
|
||||
proto = b"\x00" * 8 + struct.pack("<HH", msg_type, 0)
|
||||
return frame + frame_addr + proto + payload
|
||||
|
||||
|
||||
def _build_set_color_payload(h: int, s: int, b: int, k: int, duration_ms: int = 0) -> bytes:
|
||||
"""SetColor payload: reserved(1) | HSBK(8) | duration(4)."""
|
||||
return b"\x00" + struct.pack(
|
||||
"<HHHHI", h & 0xFFFF, s & 0xFFFF, b & 0xFFFF, k & 0xFFFF, duration_ms & 0xFFFFFFFF
|
||||
)
|
||||
|
||||
|
||||
def _build_set_power_payload(on: bool, duration_ms: int = 0) -> bytes:
|
||||
"""SetPower payload: level(2) | duration(4). Level is 0 or 65535."""
|
||||
return struct.pack("<HI", 65535 if on else 0, duration_ms & 0xFFFFFFFF)
|
||||
|
||||
|
||||
def _parse_state_service_reply(raw: bytes) -> Optional[dict]:
|
||||
"""Parse a LIFX StateService (discovery) reply.
|
||||
|
||||
Returns ``{"mac": "aabbccddeeff", "service": int, "port": int}`` or
|
||||
``None`` if the payload isn't a StateService.
|
||||
"""
|
||||
if len(raw) < 36 + 5:
|
||||
return None
|
||||
# Read msg_type from the protocol header at offset 32
|
||||
msg_type = struct.unpack_from("<H", raw, 32)[0]
|
||||
if msg_type != MSG_STATE_SERVICE:
|
||||
return None
|
||||
# Target MAC at offset 8, 6 bytes are the MAC.
|
||||
mac_bytes = raw[8:14]
|
||||
mac = mac_bytes.hex()
|
||||
# Payload at offset 36: service(1) + port(4)
|
||||
service, port = struct.unpack_from("<BI", raw, 36)
|
||||
return {"mac": mac, "service": int(service), "port": int(port)}
|
||||
|
||||
|
||||
class _LIFXProtocol(asyncio.DatagramProtocol):
|
||||
"""Write-only datagram protocol. Inbound replies dropped silently."""
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
# LIFX bulbs sometimes echo back state on broadcast. We don't need it
|
||||
# for streaming ambilight — discard.
|
||||
pass
|
||||
|
||||
def error_received(self, exc):
|
||||
logger.debug("LIFX UDP error: %s", exc)
|
||||
|
||||
|
||||
class LIFXClient(LEDClient):
|
||||
"""LEDClient for a single LIFX bulb on the LAN."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
led_count: int = 1,
|
||||
*,
|
||||
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
|
||||
):
|
||||
host, port = parse_lifx_url(url)
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._led_count = led_count
|
||||
self._min_interval_s = max(0.0, min_interval_s)
|
||||
self._transport: Optional[asyncio.DatagramTransport] = None
|
||||
self._protocol: Optional[_LIFXProtocol] = None
|
||||
self._connected = False
|
||||
self._next_tx_at: float = 0.0
|
||||
self._sequence: int = 0
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return self._port
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._transport is not None
|
||||
|
||||
@property
|
||||
def device_led_count(self) -> Optional[int]:
|
||||
return self._led_count or None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
if self._connected and self._transport is not None:
|
||||
return True
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
transport, protocol = await loop.create_datagram_endpoint(
|
||||
_LIFXProtocol, remote_addr=(self._host, self._port)
|
||||
)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Failed to open UDP to LIFX at {self._host}: {exc}") from exc
|
||||
self._transport = transport
|
||||
self._protocol = protocol # type: ignore[assignment]
|
||||
self._connected = True
|
||||
logger.info("LIFXClient connected to %s:%d", self._host, self._port)
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._transport is not None:
|
||||
try:
|
||||
self._transport.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._transport = None
|
||||
self._protocol = None
|
||||
self._connected = False
|
||||
|
||||
def _next_sequence(self) -> int:
|
||||
self._sequence = (self._sequence + 1) & 0xFF
|
||||
return self._sequence
|
||||
|
||||
def _send(self, msg_type: int, payload: bytes) -> None:
|
||||
"""Build and send one LIFX packet. Caller must hold an open transport."""
|
||||
assert self._transport is not None
|
||||
packet = _build_packet(
|
||||
msg_type=msg_type,
|
||||
payload=payload,
|
||||
sequence=self._next_sequence(),
|
||||
tagged=True, # broadcast within the unicast UDP socket — bulb still acts on it
|
||||
)
|
||||
self._transport.sendto(packet)
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
"""Average the strip → HSBK → SetColor."""
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("LIFXClient not connected")
|
||||
now = time.monotonic()
|
||||
if now < self._next_tx_at:
|
||||
return True
|
||||
r, g, b = _average_color(pixels)
|
||||
if brightness < 255:
|
||||
scale = max(0, min(255, brightness)) / 255.0
|
||||
r = int(r * scale)
|
||||
g = int(g * scale)
|
||||
b = int(b * scale)
|
||||
h, s, br, k = rgb_to_hsbk(r, g, b)
|
||||
self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0))
|
||||
self._next_tx_at = now + self._min_interval_s
|
||||
return True
|
||||
|
||||
def send_pixels_fast(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> None:
|
||||
"""Synchronous variant — same shape, runs on the hot loop."""
|
||||
if not self.is_connected or self._transport is None:
|
||||
raise RuntimeError("LIFXClient not connected")
|
||||
now = time.monotonic()
|
||||
if now < self._next_tx_at:
|
||||
return
|
||||
r, g, b = _average_color(pixels)
|
||||
if brightness < 255:
|
||||
scale = max(0, min(255, brightness)) / 255.0
|
||||
r = int(r * scale)
|
||||
g = int(g * scale)
|
||||
b = int(b * scale)
|
||||
h, s, br, k = rgb_to_hsbk(r, g, b)
|
||||
self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0))
|
||||
self._next_tx_at = now + self._min_interval_s
|
||||
|
||||
@property
|
||||
def supports_fast_send(self) -> bool:
|
||||
return True
|
||||
|
||||
async def set_color(self, r: int, g: int, b: int) -> None:
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("LIFXClient not connected")
|
||||
h, s, br, k = rgb_to_hsbk(r, g, b)
|
||||
self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0))
|
||||
|
||||
async def set_power(self, on: bool) -> None:
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("LIFXClient not connected")
|
||||
self._send(MSG_SET_POWER, _build_set_power_payload(on))
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""Send GetService and wait briefly for a StateService reply."""
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
host, port = parse_lifx_url(url)
|
||||
except ValueError as exc:
|
||||
return DeviceHealth(online=False, last_checked=now, error=str(exc))
|
||||
loop = asyncio.get_running_loop()
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setblocking(False)
|
||||
try:
|
||||
sock.bind(("", 0))
|
||||
probe = _build_packet(msg_type=MSG_GET_SERVICE, payload=b"", tagged=True)
|
||||
start = loop.time()
|
||||
await loop.sock_sendto(sock, probe, (host, port))
|
||||
try:
|
||||
await asyncio.wait_for(loop.sock_recv(sock, 4096), timeout=1.5)
|
||||
except asyncio.TimeoutError:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
last_checked=now,
|
||||
error=f"No LIFX reply from {host}:{port} within 1.5s",
|
||||
)
|
||||
latency_ms = (loop.time() - start) * 1000.0
|
||||
return DeviceHealth(online=True, latency_ms=latency_ms, last_checked=now)
|
||||
except OSError as exc:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
last_checked=now,
|
||||
error=f"LIFX probe failed for {host}: {exc}",
|
||||
)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Broadcast discovery
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def discover_lifx_bulbs(timeout: float = 2.0) -> List[dict]:
|
||||
"""Broadcast a GetService probe on the LAN and collect StateService replies.
|
||||
|
||||
Returns ``[{"ip": ..., "mac": ..., "port": ...}, ...]``.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
sock.setblocking(False)
|
||||
try:
|
||||
sock.bind(("", 0))
|
||||
probe = _build_packet(msg_type=MSG_GET_SERVICE, payload=b"", tagged=True)
|
||||
await loop.sock_sendto(sock, probe, ("255.255.255.255", LIFX_PORT))
|
||||
results: list[dict] = []
|
||||
seen_macs: set[str] = set()
|
||||
deadline = loop.time() + timeout
|
||||
while True:
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
raw, addr = await asyncio.wait_for(
|
||||
loop.sock_recvfrom(sock, 4096),
|
||||
timeout=remaining,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
parsed = _parse_state_service_reply(raw)
|
||||
if not parsed:
|
||||
continue
|
||||
mac = parsed["mac"]
|
||||
if mac in seen_macs:
|
||||
continue
|
||||
seen_macs.add(mac)
|
||||
results.append(
|
||||
{
|
||||
"ip": addr[0],
|
||||
"mac": mac,
|
||||
"port": parsed["port"],
|
||||
}
|
||||
)
|
||||
return results
|
||||
finally:
|
||||
sock.close()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""LIFX device provider — LAN-discoverable LIFX smart bulbs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from ledgrab.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
ProviderDeps,
|
||||
)
|
||||
from ledgrab.core.devices.lifx_client import (
|
||||
LIFXClient,
|
||||
discover_lifx_bulbs,
|
||||
parse_lifx_url,
|
||||
)
|
||||
from ledgrab.utils.net_classify import validate_lan_host
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ledgrab.core.devices.device_config import LIFXConfig
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LIFXDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for LIFX smart bulbs / lightstrips.
|
||||
|
||||
Single-pixel adapter: averages the strip down to one color, encodes as
|
||||
HSBK (LIFX's native color model), and broadcasts a SetColor packet.
|
||||
"""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "lifx"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
return {
|
||||
"manual_led_count",
|
||||
"power_control",
|
||||
"static_color",
|
||||
"health_check",
|
||||
"single_pixel",
|
||||
}
|
||||
|
||||
def create_client(self, config: "LIFXConfig", *, deps: ProviderDeps) -> LEDClient:
|
||||
return LIFXClient(
|
||||
config.device_url,
|
||||
led_count=config.led_count,
|
||||
min_interval_s=max(0.0, config.lifx_min_interval_ms / 1000.0),
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return await LIFXClient.check_health(url, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
try:
|
||||
host, port = parse_lifx_url(url)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid LIFX URL: {exc}") from exc
|
||||
validate_lan_host(host)
|
||||
logger.info("LIFX device URL validated: host=%s port=%d", host, port)
|
||||
return {}
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
try:
|
||||
bulbs = await discover_lifx_bulbs(timeout=min(timeout, 5.0))
|
||||
except (OSError, RuntimeError) as exc:
|
||||
logger.warning("LIFX discovery failed: %s", exc)
|
||||
return []
|
||||
|
||||
results: List[DiscoveredDevice] = []
|
||||
for bulb in bulbs:
|
||||
ip = bulb.get("ip", "")
|
||||
if not ip:
|
||||
continue
|
||||
url = f"lifx://{ip}"
|
||||
mac = bulb.get("mac", "")
|
||||
results.append(
|
||||
DiscoveredDevice(
|
||||
name=f"LIFX {mac[-6:]}" if mac else "LIFX bulb",
|
||||
url=url,
|
||||
device_type="lifx",
|
||||
ip=ip,
|
||||
mac=mac,
|
||||
led_count=None,
|
||||
version=None,
|
||||
)
|
||||
)
|
||||
logger.info("LIFX broadcast scan found %d bulb(s)", len(results))
|
||||
return results
|
||||
@@ -1,7 +1,12 @@
|
||||
"""MQTT LED client — publishes pixel data to an MQTT topic."""
|
||||
"""MQTT LED client — publishes pixel data to an MQTT topic on a configured broker.
|
||||
|
||||
The client acquires a per-source runtime from :class:`MQTTManager` at
|
||||
``connect()`` time and releases it at ``close()``. Every device references
|
||||
an ``mqtt_source_id`` so different devices can talk to different brokers.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import List, Tuple, Union
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -10,23 +15,11 @@ from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Singleton reference — injected from main.py at startup
|
||||
_mqtt_service = None
|
||||
|
||||
|
||||
def set_mqtt_service(service) -> None:
|
||||
global _mqtt_service
|
||||
_mqtt_service = service
|
||||
|
||||
|
||||
def get_mqtt_service():
|
||||
return _mqtt_service
|
||||
|
||||
|
||||
def parse_mqtt_url(url: str) -> str:
|
||||
"""Extract topic from an mqtt:// URL.
|
||||
|
||||
Format: mqtt://topic/path (broker connection is global via config)
|
||||
Format: mqtt://topic/path (broker connection is per-source, not in URL)
|
||||
"""
|
||||
if url.startswith("mqtt://"):
|
||||
return url[7:]
|
||||
@@ -34,36 +27,64 @@ def parse_mqtt_url(url: str) -> str:
|
||||
|
||||
|
||||
class MQTTLEDClient(LEDClient):
|
||||
"""Publishes JSON pixel data to an MQTT topic via the shared service."""
|
||||
"""Publishes JSON pixel data to an MQTT topic via an MQTTManager runtime."""
|
||||
|
||||
def __init__(self, url: str, led_count: int = 0, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
led_count: int = 0,
|
||||
*,
|
||||
mqtt_manager=None,
|
||||
mqtt_source_id: str = "",
|
||||
**kwargs,
|
||||
):
|
||||
self._topic = parse_mqtt_url(url)
|
||||
self._led_count = led_count
|
||||
self._mqtt_manager = mqtt_manager
|
||||
self._mqtt_source_id = mqtt_source_id
|
||||
self._runtime = None
|
||||
self._connected = False
|
||||
|
||||
async def connect(self) -> bool:
|
||||
svc = _mqtt_service
|
||||
if svc is None or not svc.is_enabled:
|
||||
raise ConnectionError("MQTT service not available")
|
||||
if not svc.is_connected:
|
||||
raise ConnectionError("MQTT service not connected to broker")
|
||||
if self._mqtt_manager is None:
|
||||
raise ConnectionError("MQTT manager not available")
|
||||
if not self._mqtt_source_id:
|
||||
raise ConnectionError("Device has no mqtt_source_id configured")
|
||||
try:
|
||||
self._runtime = await self._mqtt_manager.acquire(self._mqtt_source_id)
|
||||
except Exception as e:
|
||||
raise ConnectionError(f"Failed to acquire MQTT runtime: {e}") from e
|
||||
if not self._runtime.is_connected:
|
||||
# Runtime exists but the broker hasn't established the TCP
|
||||
# connection yet — leave the LED client in a "queued" state so
|
||||
# publishes get buffered (MQTTRuntime.publish queues on
|
||||
# disconnect). The runtime will drain when connection is made.
|
||||
logger.info(
|
||||
"MQTT LED client %s: runtime acquired but broker not yet connected",
|
||||
self._mqtt_source_id,
|
||||
)
|
||||
self._connected = True
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._runtime is not None and self._mqtt_manager is not None:
|
||||
try:
|
||||
await self._mqtt_manager.release(self._mqtt_source_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to release MQTT runtime %s: %s", self._mqtt_source_id, e)
|
||||
self._runtime = None
|
||||
self._connected = False
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and _mqtt_service is not None and _mqtt_service.is_connected
|
||||
return self._connected and self._runtime is not None and self._runtime.is_connected
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
svc = _mqtt_service
|
||||
if svc is None or not svc.is_connected:
|
||||
if self._runtime is None:
|
||||
return False
|
||||
|
||||
if isinstance(pixels, np.ndarray):
|
||||
@@ -79,7 +100,7 @@ class MQTTLEDClient(LEDClient):
|
||||
}
|
||||
)
|
||||
|
||||
await svc.publish(self._topic, payload, retain=False, qos=0)
|
||||
await self._runtime.publish(self._topic, payload, retain=False, qos=0)
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
@@ -88,16 +109,22 @@ class MQTTLEDClient(LEDClient):
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health=None,
|
||||
*,
|
||||
mqtt_manager=None,
|
||||
mqtt_source_id: Optional[str] = None,
|
||||
) -> DeviceHealth:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
svc = _mqtt_service
|
||||
if svc is None or not svc.is_enabled:
|
||||
if mqtt_manager is None or not mqtt_source_id:
|
||||
return DeviceHealth(
|
||||
online=False, error="MQTT disabled", last_checked=datetime.now(timezone.utc)
|
||||
online=False,
|
||||
error="MQTT source not configured",
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
)
|
||||
runtime = mqtt_manager.get_runtime(mqtt_source_id)
|
||||
connected = bool(runtime and runtime.is_connected)
|
||||
return DeviceHealth(
|
||||
online=svc.is_connected,
|
||||
online=connected,
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
error=None if svc.is_connected else "MQTT broker disconnected",
|
||||
error=None if connected else "MQTT broker disconnected",
|
||||
)
|
||||
|
||||
@@ -38,6 +38,8 @@ class MQTTDeviceProvider(LEDDeviceProvider):
|
||||
return MQTTLEDClient(
|
||||
config.device_url,
|
||||
led_count=config.led_count,
|
||||
mqtt_manager=deps.mqtt_manager,
|
||||
mqtt_source_id=config.mqtt_source_id,
|
||||
)
|
||||
|
||||
async def check_health(
|
||||
@@ -46,7 +48,17 @@ class MQTTDeviceProvider(LEDDeviceProvider):
|
||||
http_client,
|
||||
prev_health=None,
|
||||
) -> DeviceHealth:
|
||||
return await MQTTLEDClient.check_health(url, http_client, prev_health)
|
||||
# The provider-level check doesn't know which source the device
|
||||
# references; the runtime check is best-effort. The healthier path
|
||||
# is to wire mqtt_manager into the health monitor — for now we just
|
||||
# return "unknown" so the dashboard doesn't show a stale "online".
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
error="MQTT health requires mqtt_source_id (per-device runtime check)",
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
"""Validate MQTT device URL (topic path)."""
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Nanoleaf OpenAPI LED client.
|
||||
|
||||
Nanoleaf controllers (Light Panels, Canvas, Shapes, Lines, Elements) expose
|
||||
an HTTP REST API on port 16021. Pairing follows the documented two-step
|
||||
handshake: the user holds the controller's power button for 5 seconds to
|
||||
open a 30-second pairing window, then we POST to ``/api/v1/new`` to claim
|
||||
an auth token. The token is long-lived and gets stored on the device.
|
||||
|
||||
Once paired, color control is a simple ``PUT /api/v1/{token}/state`` with
|
||||
HSBT (hue / saturation / brightness; kelvin only matters when sat=0).
|
||||
LedGrab averages the incoming strip to one HSB triple. Per-panel streaming
|
||||
mode (``extControl`` UDP, ~60 Hz, addresses each panel individually) is
|
||||
documented but not implemented here — the MVP keeps the device acting as
|
||||
a single-pixel target like Yeelight / Hue.
|
||||
|
||||
URL scheme: ``nanoleaf://<host>``. Port is fixed at 16021 on the protocol
|
||||
side. The auth token is stored separately on the device config, not in
|
||||
the URL — putting it in the URL would leak the token into log files.
|
||||
|
||||
Reference: https://forum.nanoleaf.me/docs/openapi
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient, PairingNotReady
|
||||
from ledgrab.core.devices.pixel_reduce import average_color as _average_color
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
NANOLEAF_PORT = 16021
|
||||
DEFAULT_MIN_INTERVAL_S = 0.1 # 10 Hz; HTTP per frame, plenty for averaged ambilight
|
||||
|
||||
|
||||
def parse_nanoleaf_url(url: str) -> str:
|
||||
"""Pull the host out of ``nanoleaf://host`` or accept a bare host.
|
||||
|
||||
The TCP port is fixed at 16021 on the protocol side; we ignore any
|
||||
port specifier rather than silently accept one the controller won't
|
||||
answer on.
|
||||
"""
|
||||
if not url:
|
||||
raise ValueError("Nanoleaf URL is empty")
|
||||
raw = url.strip()
|
||||
if "://" in raw:
|
||||
parsed = urlparse(raw)
|
||||
host = parsed.hostname or ""
|
||||
else:
|
||||
parsed = urlparse(f"nanoleaf://{raw}")
|
||||
host = parsed.hostname or ""
|
||||
if not host:
|
||||
raise ValueError(f"Nanoleaf URL has no host: {url!r}")
|
||||
return host
|
||||
|
||||
|
||||
def rgb_to_hsb(r: int, g: int, b: int) -> Tuple[int, int, int]:
|
||||
"""Convert 8-bit RGB to Nanoleaf HSB (hue 0-360, sat 0-100, bri 0-100).
|
||||
|
||||
All outputs are integers; the Nanoleaf API rejects fractional values.
|
||||
"""
|
||||
r_n = max(0, min(255, r)) / 255.0
|
||||
g_n = max(0, min(255, g)) / 255.0
|
||||
b_n = max(0, min(255, b)) / 255.0
|
||||
c_max = max(r_n, g_n, b_n)
|
||||
c_min = min(r_n, g_n, b_n)
|
||||
delta = c_max - c_min
|
||||
|
||||
if delta == 0:
|
||||
h = 0.0
|
||||
elif c_max == r_n:
|
||||
h = 60.0 * (((g_n - b_n) / delta) % 6)
|
||||
elif c_max == g_n:
|
||||
h = 60.0 * (((b_n - r_n) / delta) + 2)
|
||||
else:
|
||||
h = 60.0 * (((r_n - g_n) / delta) + 4)
|
||||
hue = int(round(h)) % 360
|
||||
sat = 0 if c_max == 0 else int(round((delta / c_max) * 100))
|
||||
bri = int(round(c_max * 100))
|
||||
return hue, sat, bri
|
||||
|
||||
|
||||
async def pair_nanoleaf(host: str, timeout_s: float = 4.0) -> str:
|
||||
"""POST to ``/api/v1/new``; user must have just held the power button 5s.
|
||||
|
||||
Returns the auth token on success. Raises ``PairingNotReady`` if the
|
||||
controller responds 403 (not in pairing mode) — the caller surfaces
|
||||
that as a 409 to the UI with a retry prompt.
|
||||
"""
|
||||
base = f"http://{host}:{NANOLEAF_PORT}/api/v1/new"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout_s) as http_client:
|
||||
response = await http_client.post(base)
|
||||
except (httpx.HTTPError, httpx.TimeoutException) as exc:
|
||||
raise RuntimeError(f"Pairing transport failure for {host}: {exc}") from exc
|
||||
|
||||
if response.status_code == 403:
|
||||
raise PairingNotReady(
|
||||
"Hold the power button on your Nanoleaf controller for 5 seconds "
|
||||
"until the LEDs flash, then click Try again."
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"Nanoleaf at {host} returned HTTP {response.status_code} during pairing: "
|
||||
f"{response.text[:200]}"
|
||||
)
|
||||
try:
|
||||
token = response.json().get("auth_token")
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"Malformed pairing response from {host}: {exc}") from exc
|
||||
if not token or not isinstance(token, str):
|
||||
raise RuntimeError(f"Nanoleaf at {host} returned no auth_token in pairing response")
|
||||
return token
|
||||
|
||||
|
||||
class NanoleafClient(LEDClient):
|
||||
"""LEDClient for a single Nanoleaf controller (Panels / Canvas / Shapes)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
led_count: int = 1,
|
||||
*,
|
||||
auth_token: str = "",
|
||||
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
|
||||
request_timeout_s: float = 3.0,
|
||||
):
|
||||
self._host = parse_nanoleaf_url(url)
|
||||
self._token = auth_token
|
||||
self._led_count = led_count
|
||||
self._min_interval_s = max(0.0, min_interval_s)
|
||||
self._request_timeout_s = request_timeout_s
|
||||
self._http: Optional[httpx.AsyncClient] = None
|
||||
self._connected = False
|
||||
self._next_tx_at: float = 0.0
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
"""Fixed at the protocol-mandated 16021. Exposed for cross-driver
|
||||
symmetry with the UDP/TCP clients that also surface ``.port``."""
|
||||
return NANOLEAF_PORT
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._http is not None
|
||||
|
||||
@property
|
||||
def device_led_count(self) -> Optional[int]:
|
||||
return self._led_count or None
|
||||
|
||||
def _state_url(self) -> str:
|
||||
return f"http://{self._host}:{NANOLEAF_PORT}/api/v1/{self._token}/state"
|
||||
|
||||
async def connect(self) -> bool:
|
||||
if self._connected and self._http is not None:
|
||||
return True
|
||||
if not self._token:
|
||||
raise RuntimeError("NanoleafClient requires an auth_token; pair the device first")
|
||||
self._http = httpx.AsyncClient(timeout=self._request_timeout_s)
|
||||
self._connected = True
|
||||
logger.info("NanoleafClient connected to %s:%d", self._host, NANOLEAF_PORT)
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._http is not None:
|
||||
try:
|
||||
await self._http.aclose()
|
||||
except (httpx.HTTPError, RuntimeError):
|
||||
pass
|
||||
self._http = None
|
||||
self._connected = False
|
||||
|
||||
async def _put_state(self, body: dict) -> None:
|
||||
if self._http is None:
|
||||
raise RuntimeError("NanoleafClient not connected")
|
||||
response = await self._http.put(self._state_url(), json=body)
|
||||
# 204 No Content is the documented success; 200 also acceptable in practice.
|
||||
if response.status_code not in (200, 204):
|
||||
raise RuntimeError(
|
||||
f"Nanoleaf rejected state update ({response.status_code}): "
|
||||
f"{response.text[:200]}"
|
||||
)
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
"""Average the strip and PUT a single HSB state update."""
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("NanoleafClient not connected")
|
||||
loop_now = asyncio.get_event_loop().time()
|
||||
if loop_now < self._next_tx_at:
|
||||
return True
|
||||
r, g, b = _average_color(pixels)
|
||||
if brightness < 255:
|
||||
scale = max(0, min(255, brightness)) / 255.0
|
||||
r = int(r * scale)
|
||||
g = int(g * scale)
|
||||
b = int(b * scale)
|
||||
hue, sat, bri = rgb_to_hsb(r, g, b)
|
||||
# Nanoleaf rejects brightness=0; clamp to 1 so the user can still see
|
||||
# "almost off" without falling off the API.
|
||||
bri = max(1, bri)
|
||||
await self._put_state(
|
||||
{
|
||||
"hue": {"value": hue},
|
||||
"sat": {"value": sat},
|
||||
"brightness": {"value": bri, "duration": 0},
|
||||
}
|
||||
)
|
||||
self._next_tx_at = loop_now + self._min_interval_s
|
||||
return True
|
||||
|
||||
async def set_color(self, r: int, g: int, b: int) -> None:
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("NanoleafClient not connected")
|
||||
hue, sat, bri = rgb_to_hsb(r, g, b)
|
||||
bri = max(1, bri)
|
||||
await self._put_state(
|
||||
{
|
||||
"hue": {"value": hue},
|
||||
"sat": {"value": sat},
|
||||
"brightness": {"value": bri, "duration": 0},
|
||||
}
|
||||
)
|
||||
|
||||
async def set_power(self, on: bool) -> None:
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("NanoleafClient not connected")
|
||||
await self._put_state({"on": {"value": on}})
|
||||
|
||||
async def set_brightness(self, brightness_0_100: int) -> None:
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("NanoleafClient not connected")
|
||||
clamped = max(0, min(100, brightness_0_100))
|
||||
await self._put_state({"brightness": {"value": clamped, "duration": 0}})
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""GET ``/api/v1/<token>/info``. Without a token we can't authenticate,
|
||||
so we fall back to GET ``/api/v1`` which returns 401 when the host is
|
||||
a real Nanoleaf controller and connection-error otherwise."""
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
host = parse_nanoleaf_url(url)
|
||||
except ValueError as exc:
|
||||
return DeviceHealth(online=False, last_checked=now, error=str(exc))
|
||||
probe_url = f"http://{host}:{NANOLEAF_PORT}/api/v1"
|
||||
loop = asyncio.get_running_loop()
|
||||
start = loop.time()
|
||||
try:
|
||||
response = await http_client.get(probe_url, timeout=2.0)
|
||||
except (httpx.HTTPError, httpx.TimeoutException) as exc:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
last_checked=now,
|
||||
error=f"Nanoleaf unreachable at {host}: {exc}",
|
||||
)
|
||||
latency_ms = (loop.time() - start) * 1000.0
|
||||
# Real Nanoleaf responds 401/403 on /api/v1 without an auth token;
|
||||
# anything else may still be alive. Both signal "host is up".
|
||||
return DeviceHealth(
|
||||
online=True,
|
||||
latency_ms=latency_ms,
|
||||
last_checked=now,
|
||||
device_version=str(response.status_code),
|
||||
)
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Nanoleaf device provider — Light Panels / Canvas / Shapes / Lines / Elements."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from ledgrab.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
ProviderDeps,
|
||||
)
|
||||
from ledgrab.core.devices.nanoleaf_client import (
|
||||
NanoleafClient,
|
||||
pair_nanoleaf,
|
||||
parse_nanoleaf_url,
|
||||
)
|
||||
from ledgrab.utils.net_classify import validate_lan_host
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ledgrab.core.devices.device_config import NanoleafConfig
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class NanoleafDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for Nanoleaf controllers.
|
||||
|
||||
Treats the controller as a single-pixel target (averaged strip → one
|
||||
HSB color via ``PUT /state``). Per-panel addressing via the
|
||||
``extControl`` UDP streaming mode is a follow-up — the MVP keeps it
|
||||
simple and matches the shape of every other consumer-bulb driver.
|
||||
|
||||
Requires pairing: the user holds the controller's power button for
|
||||
five seconds to open a 30-second window, then the frontend pair
|
||||
modal POSTs to ``/api/v1/new`` via ``pair_device``.
|
||||
"""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "nanoleaf"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
return {
|
||||
"manual_led_count",
|
||||
"requires_pairing",
|
||||
"power_control",
|
||||
"brightness_control",
|
||||
"static_color",
|
||||
"health_check",
|
||||
"single_pixel",
|
||||
}
|
||||
|
||||
def create_client(self, config: "NanoleafConfig", *, deps: ProviderDeps) -> LEDClient:
|
||||
return NanoleafClient(
|
||||
config.device_url,
|
||||
led_count=config.led_count,
|
||||
auth_token=config.nanoleaf_token,
|
||||
min_interval_s=max(0.0, config.nanoleaf_min_interval_ms / 1000.0),
|
||||
)
|
||||
|
||||
async def pair_device(self, url: str) -> dict:
|
||||
"""Claim an auth token after the user has held the power button."""
|
||||
try:
|
||||
host = parse_nanoleaf_url(url)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid Nanoleaf URL: {exc}") from exc
|
||||
validate_lan_host(host)
|
||||
token = await pair_nanoleaf(host)
|
||||
return {"nanoleaf_token": token}
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return await NanoleafClient.check_health(url, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
"""Resolve the host. LED-count is user-supplied (matches the
|
||||
existing single-pixel pattern) — Nanoleaf reports panel count
|
||||
through ``panelLayout``, but for the single-color streaming
|
||||
shape it's not needed."""
|
||||
try:
|
||||
host = parse_nanoleaf_url(url)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid Nanoleaf URL: {exc}") from exc
|
||||
validate_lan_host(host)
|
||||
logger.info("Nanoleaf URL validated: host=%s", host)
|
||||
return {}
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
"""Scan via mDNS ``_nanoleafapi._tcp``.
|
||||
|
||||
Both the newer ``_nanoleafapi._tcp`` and older ``_nanoleaf._tcp``
|
||||
service types appear in the field. We query both in parallel and
|
||||
deduplicate by IP.
|
||||
"""
|
||||
try:
|
||||
from zeroconf import ServiceStateChange
|
||||
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
|
||||
except ImportError:
|
||||
logger.warning("zeroconf unavailable; skipping Nanoleaf mDNS discovery")
|
||||
return []
|
||||
|
||||
service_types = ("_nanoleafapi._tcp.local.", "_nanoleafapi_v1._tcp.local.")
|
||||
discovered: dict[str, AsyncServiceInfo] = {}
|
||||
|
||||
def _on_state_change(**kwargs):
|
||||
service_type = kwargs.get("service_type", "")
|
||||
name = kwargs.get("name", "")
|
||||
state_change = kwargs.get("state_change")
|
||||
if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated):
|
||||
discovered[name] = AsyncServiceInfo(service_type, name)
|
||||
|
||||
try:
|
||||
aiozc = AsyncZeroconf()
|
||||
except OSError as exc:
|
||||
logger.warning("Nanoleaf discovery: zeroconf init failed (%s)", exc)
|
||||
return []
|
||||
|
||||
browsers: list = []
|
||||
try:
|
||||
browsers = [
|
||||
AsyncServiceBrowser(aiozc.zeroconf, st, handlers=[_on_state_change])
|
||||
for st in service_types
|
||||
]
|
||||
await asyncio.sleep(timeout)
|
||||
for info in discovered.values():
|
||||
await info.async_request(aiozc.zeroconf, timeout=2000)
|
||||
finally:
|
||||
# Cancel browsers in finally so a CancelledError during sleep or
|
||||
# async_request still tears them down — caught by review MEDIUM #8.
|
||||
for browser in browsers:
|
||||
try:
|
||||
await browser.async_cancel()
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
try:
|
||||
await aiozc.async_close()
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
|
||||
results: List[DiscoveredDevice] = []
|
||||
seen_ips: set[str] = set()
|
||||
for name, info in discovered.items():
|
||||
addrs = info.parsed_addresses() if info else []
|
||||
if not addrs:
|
||||
continue
|
||||
ip = addrs[0]
|
||||
if ip in seen_ips:
|
||||
continue
|
||||
seen_ips.add(ip)
|
||||
service_name = name.rsplit(".", 4)[0] if "." in name else name
|
||||
results.append(
|
||||
DiscoveredDevice(
|
||||
name=service_name or "Nanoleaf",
|
||||
url=f"nanoleaf://{ip}",
|
||||
device_type="nanoleaf",
|
||||
ip=ip,
|
||||
mac="",
|
||||
led_count=None,
|
||||
version=None,
|
||||
)
|
||||
)
|
||||
logger.info("Nanoleaf mDNS scan found %d controller(s)", len(results))
|
||||
return results
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Open Pixel Control (OPC) LED client.
|
||||
|
||||
OPC is a tiny TCP-based protocol used by Fadecandy boards, OpenRGB-OPC
|
||||
bridges, art-installation controllers, and a variety of hobbyist
|
||||
LED-driver software. Each packet is a 4-byte header followed by a body:
|
||||
|
||||
[channel:1][command:1][length_hi:1][length_lo:1][body…]
|
||||
|
||||
For pixel data we use ``command=0`` (set 8-bit pixel colors) with an
|
||||
RGB body. ``channel=0`` broadcasts to every channel on the server;
|
||||
channels 1-255 address a specific output. The connection is
|
||||
persistent — open once and stream frames forever.
|
||||
|
||||
URL scheme: ``opc://<host>[:port]`` or bare ``<host>[:port]``.
|
||||
Default port 7890.
|
||||
|
||||
Reference: https://github.com/zestyping/openpixelcontrol
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
OPC_PORT = 7890
|
||||
OPC_CMD_SET_PIXELS = 0
|
||||
|
||||
|
||||
def parse_opc_url(url: str) -> Tuple[str, int]:
|
||||
"""Pull ``(host, port)`` from ``opc://host[:port]`` or bare ``host[:port]``."""
|
||||
if not url:
|
||||
raise ValueError("OPC URL is empty")
|
||||
raw = url.strip()
|
||||
if "://" in raw:
|
||||
parsed = urlparse(raw)
|
||||
host = parsed.hostname or ""
|
||||
port = parsed.port or OPC_PORT
|
||||
else:
|
||||
parsed = urlparse(f"opc://{raw}")
|
||||
host = parsed.hostname or ""
|
||||
port = parsed.port or OPC_PORT
|
||||
if not host:
|
||||
raise ValueError(f"OPC URL has no host: {url!r}")
|
||||
return host, port
|
||||
|
||||
|
||||
def _build_set_pixels_header(channel: int, body_len: int) -> bytes:
|
||||
"""Pack the 4-byte OPC header for a SET_PIXELS frame."""
|
||||
return bytes(
|
||||
[
|
||||
channel & 0xFF,
|
||||
OPC_CMD_SET_PIXELS,
|
||||
(body_len >> 8) & 0xFF,
|
||||
body_len & 0xFF,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class OPCClient(LEDClient):
|
||||
"""LEDClient for an Open Pixel Control receiver."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
led_count: int = 0,
|
||||
*,
|
||||
channel: int = 0,
|
||||
connect_timeout_s: float = 3.0,
|
||||
):
|
||||
host, port = parse_opc_url(url)
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._led_count = led_count
|
||||
self._channel = channel & 0xFF
|
||||
self._connect_timeout_s = connect_timeout_s
|
||||
self._writer: Optional[asyncio.StreamWriter] = None
|
||||
self._reader: Optional[asyncio.StreamReader] = None
|
||||
self._connected = False
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return self._port
|
||||
|
||||
@property
|
||||
def channel(self) -> int:
|
||||
return self._channel
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._writer is not None
|
||||
|
||||
@property
|
||||
def device_led_count(self) -> Optional[int]:
|
||||
return self._led_count or None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
if self._connected and self._writer is not None:
|
||||
return True
|
||||
try:
|
||||
self._reader, self._writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(self._host, self._port),
|
||||
timeout=self._connect_timeout_s,
|
||||
)
|
||||
except (OSError, asyncio.TimeoutError) as exc:
|
||||
raise RuntimeError(f"Failed to connect to OPC at {self._host}: {exc}") from exc
|
||||
self._connected = True
|
||||
logger.info(
|
||||
"OPCClient connected to %s:%d (channel=%d)",
|
||||
self._host,
|
||||
self._port,
|
||||
self._channel,
|
||||
)
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._writer is not None:
|
||||
try:
|
||||
self._writer.close()
|
||||
await self._writer.wait_closed()
|
||||
except (OSError, asyncio.CancelledError):
|
||||
pass
|
||||
self._writer = None
|
||||
self._reader = None
|
||||
self._connected = False
|
||||
|
||||
@staticmethod
|
||||
def _apply_brightness(pixels: np.ndarray, brightness: int) -> np.ndarray:
|
||||
if brightness >= 255:
|
||||
return pixels
|
||||
if brightness <= 0:
|
||||
return np.zeros_like(pixels)
|
||||
return ((pixels.astype(np.uint16) * brightness) // 255).astype(np.uint8)
|
||||
|
||||
def _as_numpy(self, pixels: Union[List[Tuple[int, int, int]], np.ndarray]) -> np.ndarray:
|
||||
if isinstance(pixels, np.ndarray):
|
||||
arr = pixels
|
||||
else:
|
||||
arr = np.asarray(pixels, dtype=np.uint8)
|
||||
if arr.dtype != np.uint8:
|
||||
arr = arr.astype(np.uint8)
|
||||
if arr.ndim == 1 and arr.shape[0] % 3 == 0:
|
||||
arr = arr.reshape(-1, 3)
|
||||
if not arr.flags["C_CONTIGUOUS"]:
|
||||
arr = np.ascontiguousarray(arr)
|
||||
return arr
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("OPCClient not connected")
|
||||
arr = self._apply_brightness(self._as_numpy(pixels), brightness)
|
||||
body = arr.tobytes()
|
||||
header = _build_set_pixels_header(self._channel, len(body))
|
||||
assert self._writer is not None
|
||||
self._writer.write(header)
|
||||
self._writer.write(body)
|
||||
await self._writer.drain()
|
||||
return True
|
||||
|
||||
def send_pixels_fast(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> None:
|
||||
"""Synchronous hot-path write. Drain runs implicitly when the OS buffer
|
||||
flushes — for the ambilight loop, dropping the await is the point."""
|
||||
if not self.is_connected or self._writer is None:
|
||||
raise RuntimeError("OPCClient not connected")
|
||||
arr = self._apply_brightness(self._as_numpy(pixels), brightness)
|
||||
body = arr.tobytes()
|
||||
header = _build_set_pixels_header(self._channel, len(body))
|
||||
self._writer.write(header)
|
||||
self._writer.write(body)
|
||||
|
||||
@property
|
||||
def supports_fast_send(self) -> bool:
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""Open a TCP connection and close it. OPC has no protocol-level
|
||||
ping; reachable TCP is the strongest signal we get."""
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
host, port = parse_opc_url(url)
|
||||
except ValueError as exc:
|
||||
return DeviceHealth(online=False, last_checked=now, error=str(exc))
|
||||
loop = asyncio.get_running_loop()
|
||||
start = loop.time()
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host, port),
|
||||
timeout=2.0,
|
||||
)
|
||||
except (OSError, asyncio.TimeoutError) as exc:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
last_checked=now,
|
||||
error=f"OPC unreachable at {host}:{port}: {exc}",
|
||||
)
|
||||
latency_ms = (loop.time() - start) * 1000.0
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except OSError:
|
||||
pass
|
||||
del reader
|
||||
return DeviceHealth(online=True, latency_ms=latency_ms, last_checked=now)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Open Pixel Control device provider — Fadecandy and OPC-compatible receivers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from ledgrab.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
ProviderDeps,
|
||||
)
|
||||
from ledgrab.core.devices.opc_client import OPCClient, parse_opc_url
|
||||
from ledgrab.utils.net_classify import validate_lan_host
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ledgrab.core.devices.device_config import OPCConfig
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class OPCDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for Open Pixel Control receivers (Fadecandy, OPC bridges, etc.).
|
||||
|
||||
OPC has no native discovery protocol — users supply an IP. The channel
|
||||
field (default 0 = broadcast to all OPC channels) routes pixel data to
|
||||
a specific output on multi-channel servers.
|
||||
"""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "opc"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
# OPC has no reply channel; no power / brightness query.
|
||||
# Software brightness still applies client-side before the frame is sent.
|
||||
return {"manual_led_count", "health_check"}
|
||||
|
||||
def create_client(self, config: "OPCConfig", *, deps: ProviderDeps) -> LEDClient:
|
||||
return OPCClient(
|
||||
config.device_url,
|
||||
led_count=config.led_count,
|
||||
channel=config.opc_channel,
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return await OPCClient.check_health(url, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
try:
|
||||
host, port = parse_opc_url(url)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid OPC URL: {exc}") from exc
|
||||
validate_lan_host(host)
|
||||
logger.info("OPC device URL validated: host=%s port=%d", host, port)
|
||||
return {}
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
"""OPC has no discovery protocol — returns empty list."""
|
||||
return []
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Pixel-reduction helpers shared across single-pixel LED clients.
|
||||
|
||||
Single-pixel devices (Yeelight, WiZ, LIFX, Govee, Nanoleaf, BLE bulbs)
|
||||
all need to collapse an N-pixel strip down to one RGB triple before
|
||||
sending it on the wire. This module is the single home for that reduction
|
||||
so the next single-pixel driver can drop the local copy.
|
||||
|
||||
Hue is the exception — its Entertainment API addresses up to seven
|
||||
zones individually, so it doesn't reduce.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def average_color(
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
) -> Tuple[int, int, int]:
|
||||
"""Reduce an N-pixel strip to one average RGB triple.
|
||||
|
||||
Accepts either a list of ``(r, g, b)`` tuples or an ``(N, 3)`` uint8
|
||||
numpy array. Empty inputs return ``(0, 0, 0)`` rather than raising so
|
||||
callers can pass through a degenerate frame without branching.
|
||||
"""
|
||||
if isinstance(pixels, np.ndarray):
|
||||
if pixels.size == 0:
|
||||
return (0, 0, 0)
|
||||
arr = pixels.reshape(-1, 3) if pixels.ndim > 1 else pixels[:3].reshape(1, 3)
|
||||
mean = arr.mean(axis=0)
|
||||
return int(mean[0]), int(mean[1]), int(mean[2])
|
||||
if not pixels:
|
||||
return (0, 0, 0)
|
||||
total_r = total_g = total_b = 0
|
||||
for r, g, b in pixels:
|
||||
total_r += r
|
||||
total_g += g
|
||||
total_b += b
|
||||
n = len(pixels)
|
||||
return total_r // n, total_g // n, total_b // n
|
||||
@@ -0,0 +1,303 @@
|
||||
"""WiZ Connected (Philips' budget tier) LAN LED client.
|
||||
|
||||
WiZ bulbs accept JSON commands as UDP datagrams on port 38899. There's no
|
||||
persistent connection — every frame is fire-and-forget — so the client is
|
||||
simpler than Yeelight and tolerates higher update rates.
|
||||
|
||||
URL scheme: ``wiz://<host>[:port]`` or bare ``<host>``. Default port 38899.
|
||||
|
||||
Discovery: UDP broadcast of a ``registration`` envelope on
|
||||
255.255.255.255:38899 — bulbs reply unicast with their MAC and state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from ledgrab.core.devices.pixel_reduce import average_color as _average_color
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
WIZ_PORT = 38899
|
||||
DEFAULT_MIN_INTERVAL_S = 0.05 # ~20 Hz cap; bulbs tolerate it, UDP costs nothing
|
||||
|
||||
|
||||
def parse_wiz_url(url: str) -> Tuple[str, int]:
|
||||
"""Pull ``(host, port)`` from ``wiz://host[:port]`` or a bare ``host[:port]``."""
|
||||
if not url:
|
||||
raise ValueError("WiZ URL is empty")
|
||||
raw = url.strip()
|
||||
if "://" in raw:
|
||||
parsed = urlparse(raw)
|
||||
host = parsed.hostname or ""
|
||||
port = parsed.port or WIZ_PORT
|
||||
else:
|
||||
parsed = urlparse(f"wiz://{raw}")
|
||||
host = parsed.hostname or ""
|
||||
port = parsed.port or WIZ_PORT
|
||||
if not host:
|
||||
raise ValueError(f"WiZ URL has no host: {url!r}")
|
||||
return host, port
|
||||
|
||||
|
||||
class _WiZProtocol(asyncio.DatagramProtocol):
|
||||
"""Minimal protocol: sends only, drops any inbound packets silently."""
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
# WiZ bulbs reply to setPilot with a small ack. We don't need it for
|
||||
# ambilight streaming — just drop the bytes on the floor.
|
||||
pass
|
||||
|
||||
def error_received(self, exc):
|
||||
# UDP errors (ICMP unreachable, route changes) surface here. Log
|
||||
# once and let the next frame retry; transient drops are normal.
|
||||
logger.debug("WiZ UDP error: %s", exc)
|
||||
|
||||
|
||||
class WiZClient(LEDClient):
|
||||
"""LEDClient for a single WiZ Connected bulb on the LAN."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
led_count: int = 1,
|
||||
*,
|
||||
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
|
||||
):
|
||||
host, port = parse_wiz_url(url)
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._led_count = led_count
|
||||
self._min_interval_s = max(0.0, min_interval_s)
|
||||
self._transport: Optional[asyncio.DatagramTransport] = None
|
||||
self._protocol: Optional[_WiZProtocol] = None
|
||||
self._connected = False
|
||||
self._next_tx_at: float = 0.0
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return self._port
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._transport is not None
|
||||
|
||||
@property
|
||||
def device_led_count(self) -> Optional[int]:
|
||||
return self._led_count or None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
if self._connected and self._transport is not None:
|
||||
return True
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
transport, protocol = await loop.create_datagram_endpoint(
|
||||
_WiZProtocol, remote_addr=(self._host, self._port)
|
||||
)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Failed to open UDP to WiZ at {self._host}: {exc}") from exc
|
||||
self._transport = transport
|
||||
self._protocol = protocol # type: ignore[assignment]
|
||||
self._connected = True
|
||||
logger.info("WiZClient connected to %s:%d", self._host, self._port)
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._transport is not None:
|
||||
try:
|
||||
self._transport.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._transport = None
|
||||
self._protocol = None
|
||||
self._connected = False
|
||||
|
||||
def _send_json(self, payload: dict) -> None:
|
||||
"""Fire one JSON UDP packet. Caller must hold an open transport."""
|
||||
assert self._transport is not None
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
self._transport.sendto(raw)
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
"""Average the pixel strip to one color and push ``setPilot``."""
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("WiZClient not connected")
|
||||
now = time.monotonic()
|
||||
if now < self._next_tx_at:
|
||||
return True
|
||||
r, g, b = _average_color(pixels)
|
||||
# WiZ has a separate "dimming" param (1-100). For ambilight we keep
|
||||
# things linear and fold brightness into the RGB scalars — that's
|
||||
# what the bulb shows anyway with state=on.
|
||||
if brightness < 255:
|
||||
scale = max(0, min(255, brightness)) / 255.0
|
||||
r = int(r * scale)
|
||||
g = int(g * scale)
|
||||
b = int(b * scale)
|
||||
self._send_json({"method": "setPilot", "params": {"r": r, "g": g, "b": b}})
|
||||
self._next_tx_at = now + self._min_interval_s
|
||||
return True
|
||||
|
||||
def send_pixels_fast(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> None:
|
||||
"""Synchronous variant for the hot path. Same shape as send_pixels."""
|
||||
if not self.is_connected or self._transport is None:
|
||||
raise RuntimeError("WiZClient not connected")
|
||||
now = time.monotonic()
|
||||
if now < self._next_tx_at:
|
||||
return
|
||||
r, g, b = _average_color(pixels)
|
||||
if brightness < 255:
|
||||
scale = max(0, min(255, brightness)) / 255.0
|
||||
r = int(r * scale)
|
||||
g = int(g * scale)
|
||||
b = int(b * scale)
|
||||
self._send_json({"method": "setPilot", "params": {"r": r, "g": g, "b": b}})
|
||||
self._next_tx_at = now + self._min_interval_s
|
||||
|
||||
@property
|
||||
def supports_fast_send(self) -> bool:
|
||||
# WiZ is UDP fire-and-forget — perfect candidate for the sync hot path.
|
||||
return True
|
||||
|
||||
async def set_color(self, r: int, g: int, b: int) -> None:
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("WiZClient not connected")
|
||||
self._send_json({"method": "setPilot", "params": {"r": r, "g": g, "b": b}})
|
||||
|
||||
async def set_brightness(self, brightness_0_100: int) -> None:
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("WiZClient not connected")
|
||||
clamped = max(10, min(100, brightness_0_100)) # WiZ rejects <10
|
||||
self._send_json({"method": "setPilot", "params": {"dimming": clamped}})
|
||||
|
||||
async def set_power(self, on: bool) -> None:
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("WiZClient not connected")
|
||||
self._send_json({"method": "setPilot", "params": {"state": on}})
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""Send a getPilot and wait briefly for any reply on a one-shot socket."""
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
host, port = parse_wiz_url(url)
|
||||
except ValueError as exc:
|
||||
return DeviceHealth(online=False, last_checked=now, error=str(exc))
|
||||
loop = asyncio.get_running_loop()
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setblocking(False)
|
||||
try:
|
||||
sock.bind(("", 0))
|
||||
start = loop.time()
|
||||
await loop.sock_sendto(sock, b'{"method":"getPilot","params":{}}', (host, port))
|
||||
try:
|
||||
await asyncio.wait_for(loop.sock_recv(sock, 4096), timeout=1.5)
|
||||
except asyncio.TimeoutError:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
last_checked=now,
|
||||
error=f"No WiZ reply from {host}:{port} within 1.5s",
|
||||
)
|
||||
latency_ms = (loop.time() - start) * 1000.0
|
||||
return DeviceHealth(online=True, latency_ms=latency_ms, last_checked=now)
|
||||
except OSError as exc:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
last_checked=now,
|
||||
error=f"WiZ probe failed for {host}: {exc}",
|
||||
)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Broadcast discovery
|
||||
# ============================================================================
|
||||
|
||||
|
||||
_DISCOVERY_REQUEST = (
|
||||
b'{"method":"registration","params":{"phoneMac":"AAAAAAAAAAAA","register":false,'
|
||||
b'"phoneIp":"0.0.0.0","id":"1"}}'
|
||||
)
|
||||
|
||||
|
||||
def _extract_mac(payload: dict) -> str:
|
||||
"""Pull a bulb MAC out of the standard ``result`` envelope, if present."""
|
||||
result = payload.get("result")
|
||||
if isinstance(result, dict):
|
||||
return str(result.get("mac", "")).lower()
|
||||
return ""
|
||||
|
||||
|
||||
async def discover_wiz_bulbs(timeout: float = 2.0) -> List[dict]:
|
||||
"""Broadcast a registration probe and collect bulb replies.
|
||||
|
||||
Returns a list of ``{"ip": ..., "mac": ..., "raw": <parsed_json>}`` dicts.
|
||||
Multicast / broadcast failures (no network, firewall) raise OSError;
|
||||
callers handle that by returning an empty discovery list.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
sock.setblocking(False)
|
||||
try:
|
||||
sock.bind(("", 0))
|
||||
await loop.sock_sendto(sock, _DISCOVERY_REQUEST, ("255.255.255.255", WIZ_PORT))
|
||||
results: list[dict] = []
|
||||
seen_ips: set[str] = set()
|
||||
deadline = loop.time() + timeout
|
||||
while True:
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
raw, addr = await asyncio.wait_for(
|
||||
loop.sock_recvfrom(sock, 2048),
|
||||
timeout=remaining,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
ip = addr[0]
|
||||
if ip in seen_ips:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8", errors="replace"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
continue
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
seen_ips.add(ip)
|
||||
results.append({"ip": ip, "mac": _extract_mac(payload), "raw": payload})
|
||||
return results
|
||||
finally:
|
||||
sock.close()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""WiZ Connected device provider — LAN-discoverable Philips budget-tier bulbs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from ledgrab.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
ProviderDeps,
|
||||
)
|
||||
from ledgrab.core.devices.wiz_client import (
|
||||
WiZClient,
|
||||
discover_wiz_bulbs,
|
||||
parse_wiz_url,
|
||||
)
|
||||
from ledgrab.utils.net_classify import validate_lan_host
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ledgrab.core.devices.device_config import WiZConfig
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class WiZDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for WiZ Connected (Philips budget-tier) bulbs.
|
||||
|
||||
Single-pixel device, identical adaptation shape as Yeelight/Hue.
|
||||
"""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "wiz"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
return {
|
||||
"manual_led_count",
|
||||
"power_control",
|
||||
"brightness_control",
|
||||
"static_color",
|
||||
"health_check",
|
||||
"single_pixel",
|
||||
}
|
||||
|
||||
def create_client(self, config: "WiZConfig", *, deps: ProviderDeps) -> LEDClient:
|
||||
return WiZClient(
|
||||
config.device_url,
|
||||
led_count=config.led_count,
|
||||
min_interval_s=max(0.0, config.wiz_min_interval_ms / 1000.0),
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return await WiZClient.check_health(url, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
try:
|
||||
host, port = parse_wiz_url(url)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid WiZ URL: {exc}") from exc
|
||||
validate_lan_host(host)
|
||||
logger.info("WiZ device URL validated: host=%s port=%d", host, port)
|
||||
return {}
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
try:
|
||||
bulbs = await discover_wiz_bulbs(timeout=min(timeout, 5.0))
|
||||
except (OSError, RuntimeError) as exc:
|
||||
logger.warning("WiZ discovery failed: %s", exc)
|
||||
return []
|
||||
|
||||
results: List[DiscoveredDevice] = []
|
||||
for bulb in bulbs:
|
||||
ip = bulb.get("ip", "")
|
||||
if not ip:
|
||||
continue
|
||||
url = f"wiz://{ip}"
|
||||
mac = bulb.get("mac", "")
|
||||
results.append(
|
||||
DiscoveredDevice(
|
||||
name=f"WiZ {mac[-6:]}" if mac else "WiZ bulb",
|
||||
url=url,
|
||||
device_type="wiz",
|
||||
ip=ip,
|
||||
mac=mac,
|
||||
led_count=None,
|
||||
version=None,
|
||||
)
|
||||
)
|
||||
logger.info("WiZ broadcast scan found %d bulb(s)", len(results))
|
||||
return results
|
||||
@@ -453,8 +453,15 @@ class WLEDClient(LEDClient):
|
||||
],
|
||||
}
|
||||
|
||||
logger.debug(f"Sending {len(pixels)} LEDs via HTTP ({len(indexed_pixels)} values)")
|
||||
logger.debug(f"Payload size: ~{len(str(payload))} bytes")
|
||||
# ``str(payload)`` previously stringified the entire indexed
|
||||
# array on every send to report a byte estimate; that was the
|
||||
# hot path. Drop the size readout — the LED count + indexed
|
||||
# value count is enough to interpret traffic and is O(1).
|
||||
logger.debug(
|
||||
"Sending %d LEDs via HTTP (%d indexed values)",
|
||||
len(pixels),
|
||||
len(indexed_pixels),
|
||||
)
|
||||
|
||||
await self._request("POST", "/json/state", json_data=payload)
|
||||
logger.debug("Successfully sent pixel colors via HTTP")
|
||||
|
||||
@@ -98,11 +98,18 @@ class WLEDDeviceProvider(LEDDeviceProvider):
|
||||
dict with 'led_count' key.
|
||||
|
||||
Raises:
|
||||
ValueError: Unsupported scheme or invalid LED count.
|
||||
httpx.ConnectError: Device unreachable.
|
||||
httpx.TimeoutException: Connection timed out.
|
||||
ValueError: Invalid LED count.
|
||||
"""
|
||||
url = _normalize_url(url)
|
||||
# Reject anything that isn't plain HTTP(S). url_scheme.infer_http_scheme
|
||||
# passes non-HTTP schemes through untouched ("javascript:", "file:",
|
||||
# "data:", etc.); without this guard those would reach httpx and
|
||||
# surface as opaque transport errors at best, or be silently misused
|
||||
# at worst.
|
||||
if not url.lower().startswith(("http://", "https://")):
|
||||
raise ValueError(f"WLED URL must use http:// or https:// scheme (got {url!r})")
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get(_join(url, "/json/info"))
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Yeelight (Xiaomi) LAN LED client.
|
||||
|
||||
Yeelight bulbs and lightstrips accept JSON-RPC commands over a plain TCP
|
||||
socket on port 55443. This client speaks the simplest useful subset —
|
||||
``set_rgb``, ``set_bright``, ``set_power``, ``get_prop`` — and averages the
|
||||
incoming pixel strip down to one RGB color (Yeelight bulbs are single-pixel
|
||||
devices, like Hue or generic BLE bulbs).
|
||||
|
||||
Rate limit: each Yeelight bulb caps inbound commands at roughly one per
|
||||
second by default. We enforce a configurable client-side gate to stay under
|
||||
that limit; faster updates would need Yeelight "music mode" (bulb dials
|
||||
back to our TCP server) which is a follow-up.
|
||||
|
||||
URL scheme: ``yeelight://<host>`` or bare ``<host>``. Port is fixed at 55443
|
||||
on the protocol side; we don't parse it from the URL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from ledgrab.core.devices.pixel_reduce import average_color as _average_color
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
YEELIGHT_PORT = 55443
|
||||
DEFAULT_MIN_INTERVAL_S = 0.5 # half a second between TX → ~2 Hz, well under the cap
|
||||
|
||||
|
||||
def parse_yeelight_url(url: str) -> str:
|
||||
"""Pull the host out of ``yeelight://host`` or accept a bare ``host``.
|
||||
|
||||
The TCP port is fixed on the protocol side (55443), so we ignore any port
|
||||
specifier rather than silently accept one the bulb won't answer on.
|
||||
"""
|
||||
if not url:
|
||||
raise ValueError("Yeelight URL is empty")
|
||||
raw = url.strip()
|
||||
if "://" in raw:
|
||||
parsed = urlparse(raw)
|
||||
host = parsed.hostname or ""
|
||||
else:
|
||||
parsed = urlparse(f"yeelight://{raw}")
|
||||
host = parsed.hostname or ""
|
||||
if not host:
|
||||
raise ValueError(f"Yeelight URL has no host: {url!r}")
|
||||
return host
|
||||
|
||||
|
||||
def _pack_rgb(r: int, g: int, b: int) -> int:
|
||||
"""Pack an (R, G, B) triple into the 24-bit integer Yeelight expects."""
|
||||
return ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF)
|
||||
|
||||
|
||||
class YeelightClient(LEDClient):
|
||||
"""LEDClient for a single Yeelight bulb / lightstrip on the LAN."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
led_count: int = 1,
|
||||
*,
|
||||
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
|
||||
connect_timeout_s: float = 3.0,
|
||||
):
|
||||
self._host = parse_yeelight_url(url)
|
||||
self._led_count = led_count
|
||||
self._min_interval_s = max(0.0, min_interval_s)
|
||||
self._connect_timeout_s = connect_timeout_s
|
||||
self._reader: Optional[asyncio.StreamReader] = None
|
||||
self._writer: Optional[asyncio.StreamWriter] = None
|
||||
self._connected = False
|
||||
self._next_tx_at: float = 0.0
|
||||
self._req_id: int = 0
|
||||
self._send_lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._writer is not None
|
||||
|
||||
@property
|
||||
def device_led_count(self) -> Optional[int]:
|
||||
return self._led_count or None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
if self._connected:
|
||||
return True
|
||||
try:
|
||||
self._reader, self._writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(self._host, YEELIGHT_PORT),
|
||||
timeout=self._connect_timeout_s,
|
||||
)
|
||||
except (OSError, asyncio.TimeoutError) as exc:
|
||||
raise RuntimeError(f"Failed to connect to Yeelight at {self._host}: {exc}") from exc
|
||||
self._connected = True
|
||||
logger.info("YeelightClient connected to %s:%d", self._host, YEELIGHT_PORT)
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._writer is not None:
|
||||
try:
|
||||
self._writer.close()
|
||||
await self._writer.wait_closed()
|
||||
except (OSError, asyncio.CancelledError):
|
||||
pass
|
||||
self._writer = None
|
||||
self._reader = None
|
||||
self._connected = False
|
||||
|
||||
def _next_id(self) -> int:
|
||||
self._req_id = (self._req_id + 1) % 1_000_000
|
||||
return self._req_id
|
||||
|
||||
async def _send(self, method: str, params: list) -> None:
|
||||
"""Fire a JSON-RPC command; replies are read-then-dropped opportunistically.
|
||||
|
||||
Yeelight's bulb sends a JSON reply per command, but for streaming
|
||||
ambient lighting we don't need to wait for it — the data is
|
||||
write-only.
|
||||
"""
|
||||
if self._writer is None:
|
||||
raise RuntimeError("YeelightClient not connected")
|
||||
payload = json.dumps({"id": self._next_id(), "method": method, "params": params}) + "\r\n"
|
||||
async with self._send_lock:
|
||||
self._writer.write(payload.encode("utf-8"))
|
||||
await self._writer.drain()
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
"""Average the pixel strip to one color and ``set_rgb``.
|
||||
|
||||
Brightness is folded in by scaling the averaged RGB rather than
|
||||
sending a separate ``set_bright`` (avoids burning a command and
|
||||
keeps animation in sync). When the configured min interval hasn't
|
||||
elapsed the call returns ``True`` without TX — the next frame will
|
||||
carry whichever color was current at the time it eventually fires.
|
||||
"""
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("YeelightClient not connected")
|
||||
now = time.monotonic()
|
||||
if now < self._next_tx_at:
|
||||
return True
|
||||
r, g, b = _average_color(pixels)
|
||||
if brightness < 255:
|
||||
scale = max(0, min(255, brightness)) / 255.0
|
||||
r = int(r * scale)
|
||||
g = int(g * scale)
|
||||
b = int(b * scale)
|
||||
packed = _pack_rgb(r, g, b)
|
||||
# ``set_rgb`` params: [color_int, effect, duration_ms].
|
||||
# "sudden" + 0ms keeps latency minimal for ambilight.
|
||||
await self._send("set_rgb", [packed, "sudden", 0])
|
||||
self._next_tx_at = now + self._min_interval_s
|
||||
return True
|
||||
|
||||
async def set_color(self, r: int, g: int, b: int) -> None:
|
||||
await self._send("set_rgb", [_pack_rgb(r, g, b), "sudden", 0])
|
||||
|
||||
async def set_brightness(self, brightness_0_100: int) -> None:
|
||||
clamped = max(1, min(100, brightness_0_100))
|
||||
await self._send("set_bright", [clamped, "sudden", 0])
|
||||
|
||||
async def set_power(self, on: bool) -> None:
|
||||
await self._send("set_power", ["on" if on else "off", "sudden", 0])
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""Health check: open the TCP socket to the bulb and close it."""
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
host = parse_yeelight_url(url)
|
||||
except ValueError as exc:
|
||||
return DeviceHealth(online=False, last_checked=now, error=str(exc))
|
||||
loop = asyncio.get_running_loop()
|
||||
start = loop.time()
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host, YEELIGHT_PORT),
|
||||
timeout=2.0,
|
||||
)
|
||||
except (OSError, asyncio.TimeoutError) as exc:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
last_checked=now,
|
||||
error=f"Yeelight unreachable at {host}:{YEELIGHT_PORT}: {exc}",
|
||||
)
|
||||
latency_ms = (loop.time() - start) * 1000.0
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except OSError:
|
||||
pass
|
||||
del reader
|
||||
return DeviceHealth(online=True, latency_ms=latency_ms, last_checked=now)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SSDP-style discovery
|
||||
# ============================================================================
|
||||
|
||||
_DISCOVER_GROUP = ("239.255.255.250", 1982)
|
||||
_DISCOVER_REQUEST = (
|
||||
"M-SEARCH * HTTP/1.1\r\n"
|
||||
"HOST: 239.255.255.250:1982\r\n"
|
||||
'MAN: "ssdp:discover"\r\n'
|
||||
"ST: wifi_bulb\r\n"
|
||||
"\r\n"
|
||||
).encode("ascii")
|
||||
|
||||
|
||||
def _parse_ssdp_response(raw: bytes) -> Optional[dict]:
|
||||
"""Parse a Yeelight discovery response into a ``{header: value}`` dict.
|
||||
|
||||
Returns ``None`` when the payload doesn't look like a Yeelight reply
|
||||
(e.g. a stray HTTP response from another SSDP service on the LAN).
|
||||
"""
|
||||
try:
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
if "yeelight://" not in text.lower():
|
||||
return None
|
||||
headers: dict = {}
|
||||
for line in text.splitlines():
|
||||
if ":" in line:
|
||||
key, _, value = line.partition(":")
|
||||
headers[key.strip().lower()] = value.strip()
|
||||
return headers
|
||||
|
||||
|
||||
async def discover_yeelight_bulbs(timeout: float = 2.0) -> List[dict]:
|
||||
"""Scan the LAN for Yeelight bulbs via the bulb-specific SSDP variant.
|
||||
|
||||
Returns a list of header-dicts (one per bulb that replied) so the caller
|
||||
can decide which fields to surface. Each dict has ``location``,
|
||||
``id``, ``model``, ``support``, ``rgb``, ``bright`` etc.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
||||
sock.setblocking(False)
|
||||
try:
|
||||
sock.bind(("", 0))
|
||||
await loop.sock_sendto(sock, _DISCOVER_REQUEST, _DISCOVER_GROUP)
|
||||
results: list[dict] = []
|
||||
seen_ids: set[str] = set()
|
||||
deadline = loop.time() + timeout
|
||||
while True:
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
raw, _addr = await asyncio.wait_for(
|
||||
loop.sock_recvfrom(sock, 2048),
|
||||
timeout=remaining,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
headers = _parse_ssdp_response(raw)
|
||||
if not headers:
|
||||
continue
|
||||
bulb_id = headers.get("id", "")
|
||||
if bulb_id and bulb_id in seen_ids:
|
||||
continue
|
||||
if bulb_id:
|
||||
seen_ids.add(bulb_id)
|
||||
results.append(headers)
|
||||
return results
|
||||
finally:
|
||||
sock.close()
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Yeelight device provider — LAN-discoverable Xiaomi smart bulbs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from ledgrab.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
ProviderDeps,
|
||||
)
|
||||
from ledgrab.core.devices.yeelight_client import (
|
||||
YeelightClient,
|
||||
discover_yeelight_bulbs,
|
||||
parse_yeelight_url,
|
||||
)
|
||||
from ledgrab.utils.net_classify import validate_lan_host
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ledgrab.core.devices.device_config import YeelightConfig
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class YeelightDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for Yeelight (Xiaomi) LAN bulbs and lightstrips.
|
||||
|
||||
Single-pixel device: the LED client averages the incoming strip down to
|
||||
one RGB color before sending. LED count is user-supplied; it controls
|
||||
the pixel-source mapping, not anything on the wire.
|
||||
"""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "yeelight"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
return {
|
||||
"manual_led_count",
|
||||
"power_control",
|
||||
"brightness_control",
|
||||
"static_color",
|
||||
"health_check",
|
||||
"single_pixel",
|
||||
}
|
||||
|
||||
def create_client(self, config: "YeelightConfig", *, deps: ProviderDeps) -> LEDClient:
|
||||
return YeelightClient(
|
||||
config.device_url,
|
||||
led_count=config.led_count,
|
||||
min_interval_s=max(0.0, config.yeelight_min_interval_ms / 1000.0),
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return await YeelightClient.check_health(url, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
"""Validate the URL is parseable. Yeelight bulbs are single-pixel so
|
||||
we don't return a led_count — the user fills it in."""
|
||||
try:
|
||||
host = parse_yeelight_url(url)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid Yeelight URL: {exc}") from exc
|
||||
validate_lan_host(host)
|
||||
logger.info("Yeelight device URL validated: host=%s", host)
|
||||
return {}
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
"""Scan the LAN via Yeelight's SSDP variant on 239.255.255.250:1982."""
|
||||
try:
|
||||
bulbs = await discover_yeelight_bulbs(timeout=min(timeout, 5.0))
|
||||
except (OSError, RuntimeError) as exc:
|
||||
# Multicast can fail on Windows when no network is up, on
|
||||
# firewalled hosts, or on Android sandboxes. Discovery is
|
||||
# best-effort — log and return empty.
|
||||
logger.warning("Yeelight discovery failed: %s", exc)
|
||||
return []
|
||||
|
||||
results: List[DiscoveredDevice] = []
|
||||
for headers in bulbs:
|
||||
location = headers.get("location", "")
|
||||
if not location:
|
||||
continue
|
||||
parsed = urlparse(location)
|
||||
host = parsed.hostname or ""
|
||||
if not host:
|
||||
continue
|
||||
url = f"yeelight://{host}"
|
||||
model = headers.get("model") or "yeelight"
|
||||
fw = headers.get("fw_ver") or None
|
||||
bulb_id = headers.get("id", "") or host
|
||||
results.append(
|
||||
DiscoveredDevice(
|
||||
name=f"Yeelight {model}".strip(),
|
||||
url=url,
|
||||
device_type="yeelight",
|
||||
ip=host,
|
||||
mac=bulb_id, # the bulb's hex id is the closest stable identifier
|
||||
led_count=None,
|
||||
version=fw,
|
||||
)
|
||||
)
|
||||
logger.info("Yeelight SSDP scan found %d bulb(s)", len(results))
|
||||
return results
|
||||
@@ -0,0 +1,123 @@
|
||||
"""One-shot migration: legacy global ``MQTTConfig`` (YAML/env) → first ``MQTTSource``.
|
||||
|
||||
Pre-multi-broker, LedGrab had a single MQTT broker configured under
|
||||
``mqtt:`` in ``config.yaml`` (and ``LEDGRAB_MQTT__*`` env vars). After the
|
||||
multi-broker refactor those settings are no longer read at runtime — every
|
||||
consumer references an ``MQTTSource`` id.
|
||||
|
||||
To avoid silently dropping the legacy broker config when upgrading, this
|
||||
migrator runs once at startup. If the source store is empty AND the
|
||||
legacy YAML/env values look configured (broker_host set or ``enabled=true``),
|
||||
it creates a single :class:`MQTTSource` named "Default Broker" from those
|
||||
values. After the first run nothing happens — the store is the source of
|
||||
truth.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _legacy_mqtt_from_env() -> Optional[dict]:
|
||||
"""Read legacy ``LEDGRAB_MQTT__*`` env vars (pydantic-settings convention)."""
|
||||
enabled = os.environ.get("LEDGRAB_MQTT__ENABLED", "").strip().lower()
|
||||
host = os.environ.get("LEDGRAB_MQTT__BROKER_HOST", "").strip()
|
||||
if not host and enabled not in ("true", "1", "yes"):
|
||||
return None
|
||||
return {
|
||||
"enabled": enabled in ("true", "1", "yes"),
|
||||
"broker_host": host or "localhost",
|
||||
"broker_port": int(os.environ.get("LEDGRAB_MQTT__BROKER_PORT", "1883") or "1883"),
|
||||
"username": os.environ.get("LEDGRAB_MQTT__USERNAME", "") or "",
|
||||
"password": os.environ.get("LEDGRAB_MQTT__PASSWORD", "") or "",
|
||||
"client_id": os.environ.get("LEDGRAB_MQTT__CLIENT_ID", "ledgrab") or "ledgrab",
|
||||
"base_topic": os.environ.get("LEDGRAB_MQTT__BASE_TOPIC", "ledgrab") or "ledgrab",
|
||||
}
|
||||
|
||||
|
||||
def _candidate_config_paths() -> list[Path]:
|
||||
"""Yield the YAML files that might hold the legacy ``mqtt:`` block."""
|
||||
paths: list[Path] = []
|
||||
override = os.environ.get("LEDGRAB_CONFIG_PATH")
|
||||
if override:
|
||||
paths.append(Path(override))
|
||||
if os.environ.get("LEDGRAB_DEMO", "").lower() in ("true", "1", "yes"):
|
||||
paths.append(Path("config/demo_config.yaml"))
|
||||
paths.append(Path("config/default_config.yaml"))
|
||||
return paths
|
||||
|
||||
|
||||
def _legacy_mqtt_from_yaml() -> Optional[dict]:
|
||||
"""Read legacy ``mqtt:`` block from the platform config.yaml."""
|
||||
for path in _candidate_config_paths():
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
doc = yaml.safe_load(f) or {}
|
||||
except Exception as e:
|
||||
logger.debug("Could not read %s for MQTT migration: %s", path, e)
|
||||
continue
|
||||
block = doc.get("mqtt") if isinstance(doc, dict) else None
|
||||
if isinstance(block, dict):
|
||||
host = (block.get("broker_host") or "").strip()
|
||||
enabled = bool(block.get("enabled"))
|
||||
if host or enabled:
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"broker_host": host or "localhost",
|
||||
"broker_port": int(block.get("broker_port") or 1883),
|
||||
"username": str(block.get("username") or ""),
|
||||
"password": str(block.get("password") or ""),
|
||||
"client_id": str(block.get("client_id") or "ledgrab"),
|
||||
"base_topic": str(block.get("base_topic") or "ledgrab"),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def migrate_legacy_mqtt_config(store: MQTTSourceStore) -> None:
|
||||
"""Seed a "Default Broker" :class:`MQTTSource` if the store is empty
|
||||
and a legacy YAML/env MQTT config is detected.
|
||||
|
||||
Idempotent: if the store already has any sources, this is a no-op.
|
||||
Logs a deprecation warning when migration runs.
|
||||
"""
|
||||
if store.count() > 0:
|
||||
return
|
||||
|
||||
legacy = _legacy_mqtt_from_env() or _legacy_mqtt_from_yaml()
|
||||
if legacy is None:
|
||||
return
|
||||
|
||||
try:
|
||||
source = store.create_source(
|
||||
name="Default Broker",
|
||||
broker_host=legacy["broker_host"],
|
||||
broker_port=legacy["broker_port"],
|
||||
username=legacy["username"],
|
||||
password=legacy["password"],
|
||||
client_id=legacy["client_id"],
|
||||
base_topic=legacy["base_topic"],
|
||||
description="Migrated from legacy mqtt: config block",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to migrate legacy MQTT config: %s", e)
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
"Migrated legacy MQTT config to source %s (%s:%d). "
|
||||
"The 'mqtt:' YAML block and LEDGRAB_MQTT__* env vars are no longer "
|
||||
"read; edit the broker in the UI under MQTT Sources from now on.",
|
||||
source.id,
|
||||
source.broker_host,
|
||||
source.broker_port,
|
||||
)
|
||||
@@ -49,6 +49,32 @@ class MQTTRuntime:
|
||||
# Pending publishes queued while disconnected
|
||||
self._publish_queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
|
||||
|
||||
# Strong references for fire-and-forget callback dispatch tasks.
|
||||
# Python 3.11+ may GC bare ``asyncio.create_task(...)`` results mid-
|
||||
# flight, so we hold each task until it completes and surface any
|
||||
# exception via the done-callback.
|
||||
self._dispatch_tasks: Set[asyncio.Task] = set()
|
||||
|
||||
# Compiled ``aiomqtt.Topic`` cache, keyed by the subscription pattern
|
||||
# string. The previous dispatch loop re-parsed every pattern on
|
||||
# every incoming message — on a chatty broker with many wildcards
|
||||
# that adds up fast.
|
||||
self._compiled_topics: Dict[str, aiomqtt.Topic] = {}
|
||||
|
||||
def _on_dispatch_done(self, task: asyncio.Task) -> None:
|
||||
"""Drop the strong reference and surface any callback exception."""
|
||||
self._dispatch_tasks.discard(task)
|
||||
if task.cancelled():
|
||||
return
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
logger.error(
|
||||
"MQTT async callback raised (%s): %s",
|
||||
self._source_id,
|
||||
exc,
|
||||
exc_info=exc,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected
|
||||
@@ -84,6 +110,14 @@ class MQTTRuntime:
|
||||
logger.debug("MQTT runtime task cancelled: %s", self._source_id)
|
||||
self._task = None
|
||||
self._connected = False
|
||||
# Cancel any in-flight async dispatch callbacks. Without this they
|
||||
# would keep running past the runtime's logical end of life and
|
||||
# could fire callbacks on a stopped subscriber.
|
||||
for task in list(self._dispatch_tasks):
|
||||
task.cancel()
|
||||
if self._dispatch_tasks:
|
||||
await asyncio.gather(*self._dispatch_tasks, return_exceptions=True)
|
||||
self._dispatch_tasks.clear()
|
||||
logger.info("MQTT runtime stopped: %s", self._source_id)
|
||||
|
||||
def update_config(self, source: MQTTSource) -> None:
|
||||
@@ -167,13 +201,23 @@ class MQTTRuntime:
|
||||
for topic in self._subscriptions:
|
||||
await client.subscribe(topic)
|
||||
|
||||
# Drain pending publishes
|
||||
# Drain pending publishes. A single publish failing
|
||||
# (broker rejection, oversized message) must not lose
|
||||
# the rest of the queue — log and continue with the next.
|
||||
while not self._publish_queue.empty():
|
||||
try:
|
||||
t, p, r, q = self._publish_queue.get_nowait()
|
||||
await client.publish(t, p, retain=r, qos=q)
|
||||
except Exception:
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
try:
|
||||
await client.publish(t, p, retain=r, qos=q)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"MQTT drain publish failed (%s -> %s): %s",
|
||||
self._source_id,
|
||||
t,
|
||||
exc,
|
||||
)
|
||||
|
||||
# Message receive loop
|
||||
async for msg in client.messages:
|
||||
@@ -183,13 +227,21 @@ class MQTTRuntime:
|
||||
)
|
||||
self._topic_cache[topic_str] = payload_str
|
||||
|
||||
# Dispatch to callbacks
|
||||
# Dispatch to callbacks. Pattern objects are cached
|
||||
# per subscription string to avoid re-parsing them on
|
||||
# every received message.
|
||||
for sub_topic, callbacks in self._subscriptions.items():
|
||||
if aiomqtt.Topic(sub_topic).matches(msg.topic):
|
||||
compiled = self._compiled_topics.get(sub_topic)
|
||||
if compiled is None:
|
||||
compiled = aiomqtt.Topic(sub_topic)
|
||||
self._compiled_topics[sub_topic] = compiled
|
||||
if compiled.matches(msg.topic):
|
||||
for cb in callbacks:
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(cb):
|
||||
asyncio.create_task(cb(topic_str, payload_str))
|
||||
task = asyncio.create_task(cb(topic_str, payload_str))
|
||||
self._dispatch_tasks.add(task)
|
||||
task.add_done_callback(self._on_dispatch_done)
|
||||
else:
|
||||
cb(topic_str, payload_str)
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
"""Singleton async MQTT service — shared broker connection for all features."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Callable, Dict, Optional, Set
|
||||
|
||||
import aiomqtt
|
||||
|
||||
from ledgrab.config import MQTTConfig
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MQTTService:
|
||||
"""Manages a persistent MQTT broker connection.
|
||||
|
||||
Features:
|
||||
- Publish messages (retained or transient)
|
||||
- Subscribe to topics with callback dispatch
|
||||
- Topic value cache for synchronous reads (automation condition evaluation)
|
||||
- Auto-reconnect loop
|
||||
- Birth / will messages for online status
|
||||
"""
|
||||
|
||||
def __init__(self, config: MQTTConfig):
|
||||
self._config = config
|
||||
self._client: Optional[aiomqtt.Client] = None
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._connected = False
|
||||
|
||||
# Subscription management
|
||||
self._subscriptions: Dict[str, Set[Callable]] = {} # topic -> set of callbacks
|
||||
self._topic_cache: Dict[str, str] = {} # topic -> last payload string
|
||||
|
||||
# Pending publishes queued while disconnected
|
||||
self._publish_queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
return self._config.enabled
|
||||
|
||||
async def start(self) -> None:
|
||||
if not self._config.enabled:
|
||||
logger.info("MQTT service disabled in configuration")
|
||||
return
|
||||
if self._task is not None:
|
||||
return
|
||||
self._task = asyncio.create_task(self._connection_loop())
|
||||
logger.info(
|
||||
f"MQTT service starting — broker {self._config.broker_host}:{self._config.broker_port}"
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task is None:
|
||||
return
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("MQTT background task cancelled")
|
||||
pass
|
||||
self._task = None
|
||||
self._connected = False
|
||||
logger.info("MQTT service stopped")
|
||||
|
||||
async def publish(self, topic: str, payload: str, retain: bool = False, qos: int = 0) -> None:
|
||||
"""Publish a message. If disconnected, queue for later delivery."""
|
||||
if not self._config.enabled:
|
||||
return
|
||||
if self._connected and self._client:
|
||||
try:
|
||||
await self._client.publish(topic, payload, retain=retain, qos=qos)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"MQTT publish failed ({topic}): {e}")
|
||||
# Queue for retry
|
||||
try:
|
||||
self._publish_queue.put_nowait((topic, payload, retain, qos))
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("MQTT publish queue full, dropping message for topic %s", topic)
|
||||
pass
|
||||
|
||||
async def subscribe(self, topic: str, callback: Callable) -> None:
|
||||
"""Subscribe to a topic. Callback receives (topic: str, payload: str)."""
|
||||
if topic not in self._subscriptions:
|
||||
self._subscriptions[topic] = set()
|
||||
self._subscriptions[topic].add(callback)
|
||||
|
||||
# Subscribe on the live client if connected
|
||||
if self._connected and self._client:
|
||||
try:
|
||||
await self._client.subscribe(topic)
|
||||
except Exception as e:
|
||||
logger.warning(f"MQTT subscribe failed ({topic}): {e}")
|
||||
|
||||
def get_last_value(self, topic: str) -> Optional[str]:
|
||||
"""Get cached last value for a topic (synchronous — for automation evaluation)."""
|
||||
return self._topic_cache.get(topic)
|
||||
|
||||
async def _connection_loop(self) -> None:
|
||||
"""Persistent connection loop with auto-reconnect."""
|
||||
base_topic = self._config.base_topic
|
||||
will_topic = f"{base_topic}/status"
|
||||
will_payload = "offline"
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with aiomqtt.Client(
|
||||
hostname=self._config.broker_host,
|
||||
port=self._config.broker_port,
|
||||
username=self._config.username or None,
|
||||
password=self._config.password or None,
|
||||
identifier=self._config.client_id,
|
||||
will=aiomqtt.Will(
|
||||
topic=will_topic,
|
||||
payload=will_payload,
|
||||
retain=True,
|
||||
),
|
||||
) as client:
|
||||
self._client = client
|
||||
self._connected = True
|
||||
logger.info("MQTT connected to broker")
|
||||
|
||||
# Publish birth message
|
||||
await client.publish(will_topic, "online", retain=True)
|
||||
|
||||
# Re-subscribe to all registered topics
|
||||
for topic in self._subscriptions:
|
||||
await client.subscribe(topic)
|
||||
|
||||
# Drain pending publishes
|
||||
while not self._publish_queue.empty():
|
||||
try:
|
||||
t, p, r, q = self._publish_queue.get_nowait()
|
||||
await client.publish(t, p, retain=r, qos=q)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
# Message receive loop
|
||||
async for msg in client.messages:
|
||||
topic_str = str(msg.topic)
|
||||
payload_str = (
|
||||
msg.payload.decode("utf-8", errors="replace") if msg.payload else ""
|
||||
)
|
||||
self._topic_cache[topic_str] = payload_str
|
||||
|
||||
# Dispatch to callbacks
|
||||
for sub_topic, callbacks in self._subscriptions.items():
|
||||
if aiomqtt.Topic(sub_topic).matches(msg.topic):
|
||||
for cb in callbacks:
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(cb):
|
||||
asyncio.create_task(cb(topic_str, payload_str))
|
||||
else:
|
||||
cb(topic_str, payload_str)
|
||||
except Exception as e:
|
||||
logger.error(f"MQTT callback error ({topic_str}): {e}")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
self._connected = False
|
||||
self._client = None
|
||||
logger.warning(f"MQTT connection lost: {e}. Reconnecting in 5s...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# ===== State exposure helpers =====
|
||||
|
||||
async def publish_target_state(self, target_id: str, state: dict) -> None:
|
||||
"""Publish target state to MQTT (called from event handler)."""
|
||||
topic = f"{self._config.base_topic}/target/{target_id}/state"
|
||||
await self.publish(topic, json.dumps(state), retain=True)
|
||||
|
||||
async def publish_automation_state(self, automation_id: str, action: str) -> None:
|
||||
"""Publish automation state change to MQTT."""
|
||||
topic = f"{self._config.base_topic}/automation/{automation_id}/state"
|
||||
await self.publish(topic, json.dumps({"action": action}), retain=True)
|
||||
@@ -9,12 +9,12 @@ from .base import ColorStripStream, _SimpleNoise1D, _gradient_noise
|
||||
from .gradient import GradientColorStripStream
|
||||
from .helpers import _compute_gradient_colors
|
||||
from .picture import PictureColorStripStream
|
||||
from .static import StaticColorStripStream
|
||||
from .single import SingleColorStripStream
|
||||
|
||||
__all__ = [
|
||||
"ColorStripStream",
|
||||
"PictureColorStripStream",
|
||||
"StaticColorStripStream",
|
||||
"SingleColorStripStream",
|
||||
"GradientColorStripStream",
|
||||
"_compute_gradient_colors",
|
||||
"_SimpleNoise1D",
|
||||
|
||||
@@ -7,6 +7,13 @@ from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import cv2
|
||||
|
||||
_HAS_CV2 = True
|
||||
except ImportError:
|
||||
_HAS_CV2 = False
|
||||
|
||||
from ledgrab.core.capture.calibration import (
|
||||
AdvancedPixelMapper,
|
||||
CalibrationConfig,
|
||||
@@ -203,6 +210,11 @@ class PictureColorStripStream(ColorStripStream):
|
||||
def _processing_loop(self) -> None:
|
||||
"""Background thread: poll source, process, cache colors."""
|
||||
cached_frame = None
|
||||
# Track the source LiveStream's frame_id for event-driven waits.
|
||||
# When the source publishes a new frame, ``wait_for_new_frame`` wakes
|
||||
# us immediately instead of waiting for our own polling tick — cuts
|
||||
# glass-to-LED latency by up to a full frame_time at matched FPS.
|
||||
last_source_id = self._live_stream.current_frame_id
|
||||
|
||||
# Scratch buffer pool (pre-allocated, resized when LED count changes)
|
||||
_pool_n = 0
|
||||
@@ -213,8 +225,14 @@ class PictureColorStripStream(ColorStripStream):
|
||||
def _blend_u16(a, b, alpha_b, out):
|
||||
"""Blend two uint8 arrays: out = ((256-alpha_b)*a + alpha_b*b) >> 8.
|
||||
|
||||
Uses pre-allocated uint16 scratch buffers (_u16_a, _u16_b).
|
||||
Uses ``cv2.addWeighted`` (single SIMD-fused pass) when available;
|
||||
falls back to a 6-pass numpy implementation using pre-allocated
|
||||
uint16 scratch buffers (_u16_a, _u16_b).
|
||||
"""
|
||||
if _HAS_CV2:
|
||||
w_b = alpha_b / 256.0
|
||||
cv2.addWeighted(a, 1.0 - w_b, b, w_b, 0.0, dst=out)
|
||||
return
|
||||
nonlocal _u16_a, _u16_b
|
||||
np.copyto(_u16_a, a, casting="unsafe")
|
||||
np.copyto(_u16_b, b, casting="unsafe")
|
||||
@@ -229,6 +247,7 @@ class PictureColorStripStream(ColorStripStream):
|
||||
try:
|
||||
with high_resolution_timer():
|
||||
while self._running:
|
||||
loop_start = time.perf_counter()
|
||||
limiter.begin()
|
||||
frame_time = self._frame_time
|
||||
|
||||
@@ -236,10 +255,19 @@ class PictureColorStripStream(ColorStripStream):
|
||||
frame = self._live_stream.get_latest_frame()
|
||||
|
||||
if frame is None or frame is cached_frame:
|
||||
limiter.wait(frame_time)
|
||||
# Event-driven wait on the source: returns
|
||||
# immediately when a new frame lands, or after
|
||||
# frame_time as a safety timeout.
|
||||
elapsed = time.perf_counter() - loop_start
|
||||
remaining = max(frame_time - elapsed, 0.0)
|
||||
if remaining > 0:
|
||||
last_source_id = self._live_stream.wait_for_new_frame(
|
||||
last_source_id, timeout=remaining
|
||||
)
|
||||
continue
|
||||
|
||||
cached_frame = frame
|
||||
last_source_id = self._live_stream.current_frame_id
|
||||
|
||||
t0 = time.perf_counter()
|
||||
# Record the new frame in the rolling 1s window
|
||||
@@ -255,12 +283,17 @@ class PictureColorStripStream(ColorStripStream):
|
||||
mapper = self._pixel_mapper
|
||||
|
||||
if isinstance(mapper, AdvancedPixelMapper):
|
||||
# Advanced mode: gather frames from all live streams
|
||||
# Advanced mode: gather frames from all live streams.
|
||||
# Reuse the already-sampled primary frame to avoid
|
||||
# an extra lock acquisition on it.
|
||||
frames_dict = {}
|
||||
for ps_id, ls in self._live_streams.items():
|
||||
f = ls.get_latest_frame()
|
||||
if f is not None:
|
||||
frames_dict[ps_id] = f
|
||||
if ls is self._live_stream:
|
||||
frames_dict[ps_id] = frame
|
||||
else:
|
||||
f = ls.get_latest_frame()
|
||||
if f is not None:
|
||||
frames_dict[ps_id] = f
|
||||
t1 = time.perf_counter()
|
||||
led_colors = mapper.map_lines_to_leds(frames_dict)
|
||||
else:
|
||||
|
||||
+13
-13
@@ -1,4 +1,4 @@
|
||||
"""Static color strip stream — solid color with optional animation."""
|
||||
"""Single color strip stream — solid color with optional animation."""
|
||||
|
||||
import colorsys
|
||||
import math
|
||||
@@ -18,7 +18,7 @@ from .base import ColorStripStream
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class StaticColorStripStream(ColorStripStream):
|
||||
class SingleColorStripStream(ColorStripStream):
|
||||
"""Color strip stream that returns a constant single-color array.
|
||||
|
||||
When animation is enabled a 30 fps background thread updates _colors with
|
||||
@@ -28,7 +28,7 @@ class StaticColorStripStream(ColorStripStream):
|
||||
def __init__(self, source):
|
||||
"""
|
||||
Args:
|
||||
source: StaticColorStripSource config
|
||||
source: SingleColorStripSource config
|
||||
"""
|
||||
self._colors_lock = threading.Lock()
|
||||
self._running = False
|
||||
@@ -64,7 +64,7 @@ class StaticColorStripStream(ColorStripStream):
|
||||
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
|
||||
self._led_count = device_led_count
|
||||
self._rebuild_colors()
|
||||
logger.debug(f"StaticColorStripStream auto-sized to {device_led_count} LEDs")
|
||||
logger.debug(f"SingleColorStripStream auto-sized to {device_led_count} LEDs")
|
||||
|
||||
@property
|
||||
def target_fps(self) -> int:
|
||||
@@ -98,36 +98,36 @@ class StaticColorStripStream(ColorStripStream):
|
||||
self._running = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._animate_loop,
|
||||
name="css-static-animate",
|
||||
name="css-single-animate",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info(f"StaticColorStripStream started (leds={self._led_count})")
|
||||
logger.info(f"SingleColorStripStream started (leds={self._led_count})")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5.0)
|
||||
if self._thread.is_alive():
|
||||
logger.warning("StaticColorStripStream animate thread did not terminate within 5s")
|
||||
logger.warning("SingleColorStripStream animate thread did not terminate within 5s")
|
||||
self._thread = None
|
||||
logger.info("StaticColorStripStream stopped")
|
||||
logger.info("SingleColorStripStream stopped")
|
||||
|
||||
def get_latest_colors(self) -> Optional[np.ndarray]:
|
||||
with self._colors_lock:
|
||||
return self._colors
|
||||
|
||||
def update_source(self, source) -> None:
|
||||
from ledgrab.storage.color_strip_source import StaticColorStripSource
|
||||
from ledgrab.storage.color_strip_source import SingleColorStripSource
|
||||
|
||||
if isinstance(source, StaticColorStripSource):
|
||||
if isinstance(source, SingleColorStripSource):
|
||||
prev_led_count = self._led_count if self._auto_size else None
|
||||
self._update_from_source(source)
|
||||
# If we were auto-sized, preserve the runtime LED count across updates
|
||||
if prev_led_count and self._auto_size:
|
||||
self._led_count = prev_led_count
|
||||
self._rebuild_colors()
|
||||
logger.info("StaticColorStripStream params updated in-place")
|
||||
logger.info("SingleColorStripStream params updated in-place")
|
||||
|
||||
def set_clock(self, clock) -> None:
|
||||
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
|
||||
@@ -266,7 +266,7 @@ class StaticColorStripStream(ColorStripStream):
|
||||
with self._colors_lock:
|
||||
self._colors = buf
|
||||
except Exception as e:
|
||||
logger.error(f"StaticColorStripStream animation error: {e}")
|
||||
logger.error(f"SingleColorStripStream animation error: {e}")
|
||||
|
||||
if (anim and anim.get("enabled")) or self._is_color_bound():
|
||||
sleep_target = frame_time
|
||||
@@ -274,6 +274,6 @@ class StaticColorStripStream(ColorStripStream):
|
||||
sleep_target = 0.25
|
||||
limiter.wait(sleep_target)
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal StaticColorStripStream loop error: {e}", exc_info=True)
|
||||
logger.error(f"Fatal SingleColorStripStream loop error: {e}", exc_info=True)
|
||||
finally:
|
||||
self._running = False
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Single source of truth for non-sharable color-strip stream construction.
|
||||
|
||||
Both the preview WebSocket (``api/routes/color_strip_sources/ws_stream.py``)
|
||||
and the production ``ColorStripStreamManager.acquire`` used to maintain
|
||||
parallel ``if source.source_type == "..." elif ... else ..._SIMPLE_STREAM_MAP``
|
||||
chains. Adding a new kind required keeping those two chains in lockstep, and
|
||||
silently fell through to a generic stream class when an entry was missed.
|
||||
|
||||
This module replaces both chains with a single ``STREAM_BUILDERS`` registry
|
||||
plus a small ``StreamDeps`` dependency bag. Each caller populates the bag
|
||||
from its own context (DI container, processor manager, etc.) and looks the
|
||||
builder up by ``source.source_type``. A coverage assertion at import time
|
||||
guarantees every kind in ``storage._SOURCE_TYPE_MAP`` is either sharable or
|
||||
has a builder here — silent fall-throughs are no longer possible.
|
||||
|
||||
Sharable kinds (``picture``, ``picture_advanced``, ``key_colors``) are NOT in
|
||||
this registry: they require an injected ``LiveStream`` whose acquisition is
|
||||
intertwined with the source's calibration, which does not fit a uniform
|
||||
factory signature. Those continue to use bespoke paths inside
|
||||
``ColorStripStreamManager``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StreamDeps:
|
||||
"""Dependency bag for non-sharable stream construction.
|
||||
|
||||
Each call site (preview WebSocket, production stream manager) builds one
|
||||
of these from its own context before invoking :func:`build_stream`.
|
||||
Fields are ``Any`` (with ``None`` defaults) because individual builders
|
||||
only consume a subset; tests can supply a minimal bag.
|
||||
|
||||
``frozen=True`` guards against a builder accidentally reassigning a
|
||||
field; it does NOT make the referenced objects immutable — the
|
||||
``css_manager``, stores, etc. are live mutable services.
|
||||
|
||||
``css_manager`` is needed by composite/mapped/processed builders so they
|
||||
can recursively acquire dependent streams. Single-kind builders ignore
|
||||
it. The field has no default so callers are forced to think about which
|
||||
manager they are wiring through.
|
||||
"""
|
||||
|
||||
css_manager: Any
|
||||
value_stream_manager: Any = None
|
||||
cspt_store: Any = None # ColorStripProcessingTemplateStore
|
||||
weather_manager: Any = None
|
||||
audio_capture_manager: Any = None
|
||||
audio_source_store: Any = None
|
||||
audio_template_store: Any = None
|
||||
audio_processing_template_store: Any = None
|
||||
game_event_bus: Any = None
|
||||
depth: int = 0 # composite nesting depth — passed through verbatim
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-kind builders
|
||||
#
|
||||
# Each builder is a small free function ``(source, deps) -> ColorStripStream``.
|
||||
# Imports are deferred to keep this module cheap to import (the production
|
||||
# processing graph is large).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_audio(source, d: StreamDeps):
|
||||
from ledgrab.core.processing.audio_stream import AudioColorStripStream
|
||||
|
||||
return AudioColorStripStream(
|
||||
source,
|
||||
d.audio_capture_manager,
|
||||
d.audio_source_store,
|
||||
d.audio_template_store,
|
||||
d.audio_processing_template_store,
|
||||
)
|
||||
|
||||
|
||||
def _build_composite(source, d: StreamDeps):
|
||||
from ledgrab.core.processing.composite_stream import CompositeColorStripStream
|
||||
|
||||
return CompositeColorStripStream(
|
||||
source,
|
||||
d.css_manager,
|
||||
d.value_stream_manager,
|
||||
d.cspt_store,
|
||||
depth=d.depth,
|
||||
)
|
||||
|
||||
|
||||
def _build_mapped(source, d: StreamDeps):
|
||||
from ledgrab.core.processing.mapped_stream import MappedColorStripStream
|
||||
|
||||
return MappedColorStripStream(source, d.css_manager)
|
||||
|
||||
|
||||
def _build_processed(source, d: StreamDeps):
|
||||
from ledgrab.core.processing.processed_stream import ProcessedColorStripStream
|
||||
|
||||
return ProcessedColorStripStream(source, d.css_manager, d.cspt_store)
|
||||
|
||||
|
||||
def _build_weather(source, d: StreamDeps):
|
||||
from ledgrab.core.processing.weather_stream import WeatherColorStripStream
|
||||
|
||||
return WeatherColorStripStream(source, d.weather_manager)
|
||||
|
||||
|
||||
def _build_game_event(source, d: StreamDeps):
|
||||
from ledgrab.core.processing.game_event_stream import GameEventColorStripStream
|
||||
|
||||
stream = GameEventColorStripStream(source)
|
||||
if d.game_event_bus is not None:
|
||||
stream.set_event_bus(d.game_event_bus)
|
||||
return stream
|
||||
|
||||
|
||||
def _make_source_only_builder(loader: Callable[[], type]) -> Callable[[Any, StreamDeps], Any]:
|
||||
"""Wrap a class-loader so it produces a uniform ``(source, deps) -> stream`` builder.
|
||||
|
||||
The loader is called on each invocation but module caching makes the
|
||||
import a single dict lookup after the first call.
|
||||
"""
|
||||
|
||||
def _build(source, _deps: StreamDeps):
|
||||
return loader()(source)
|
||||
|
||||
return _build
|
||||
|
||||
|
||||
def _single_color_cls() -> type:
|
||||
from ledgrab.core.processing.color_strip_stream import SingleColorStripStream
|
||||
|
||||
return SingleColorStripStream
|
||||
|
||||
|
||||
def _gradient_cls() -> type:
|
||||
from ledgrab.core.processing.color_strip_stream import GradientColorStripStream
|
||||
|
||||
return GradientColorStripStream
|
||||
|
||||
|
||||
def _effect_cls() -> type:
|
||||
from ledgrab.core.processing.effect_stream import EffectColorStripStream
|
||||
|
||||
return EffectColorStripStream
|
||||
|
||||
|
||||
def _api_input_cls() -> type:
|
||||
from ledgrab.core.processing.api_input_stream import ApiInputColorStripStream
|
||||
|
||||
return ApiInputColorStripStream
|
||||
|
||||
|
||||
def _notification_cls() -> type:
|
||||
from ledgrab.core.processing.notification_stream import NotificationColorStripStream
|
||||
|
||||
return NotificationColorStripStream
|
||||
|
||||
|
||||
def _daylight_cls() -> type:
|
||||
from ledgrab.core.processing.daylight_stream import DaylightColorStripStream
|
||||
|
||||
return DaylightColorStripStream
|
||||
|
||||
|
||||
def _candlelight_cls() -> type:
|
||||
from ledgrab.core.processing.candlelight_stream import CandlelightColorStripStream
|
||||
|
||||
return CandlelightColorStripStream
|
||||
|
||||
|
||||
def _math_wave_cls() -> type:
|
||||
from ledgrab.core.processing.math_wave_stream import MathWaveColorStripStream
|
||||
|
||||
return MathWaveColorStripStream
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
StreamBuilder = Callable[[Any, StreamDeps], Any]
|
||||
|
||||
STREAM_BUILDERS: dict[str, StreamBuilder] = {
|
||||
"audio": _build_audio,
|
||||
"composite": _build_composite,
|
||||
"mapped": _build_mapped,
|
||||
"processed": _build_processed,
|
||||
"weather": _build_weather,
|
||||
"game_event": _build_game_event,
|
||||
"single_color": _make_source_only_builder(_single_color_cls),
|
||||
# Legacy alias: pre-rename rows used "static". The data migration rewrites
|
||||
# the on-disk source_type on startup, but this alias keeps an in-flight
|
||||
# legacy entry resolving to the right stream class.
|
||||
"static": _make_source_only_builder(_single_color_cls),
|
||||
"gradient": _make_source_only_builder(_gradient_cls),
|
||||
"effect": _make_source_only_builder(_effect_cls),
|
||||
"api_input": _make_source_only_builder(_api_input_cls),
|
||||
"notification": _make_source_only_builder(_notification_cls),
|
||||
"daylight": _make_source_only_builder(_daylight_cls),
|
||||
"candlelight": _make_source_only_builder(_candlelight_cls),
|
||||
"math_wave": _make_source_only_builder(_math_wave_cls),
|
||||
}
|
||||
|
||||
|
||||
# Sharable kinds are handled by dedicated LiveStream-acquisition paths in
|
||||
# ColorStripStreamManager (their construction depends on calibration → picture
|
||||
# source resolution, which does not fit a uniform factory signature).
|
||||
SHARABLE_KINDS: frozenset[str] = frozenset({"picture", "picture_advanced", "key_colors"})
|
||||
|
||||
|
||||
def build_stream(source, deps: StreamDeps):
|
||||
"""Build a non-sharable color-strip stream for *source*.
|
||||
|
||||
Raises ``ValueError`` if the kind has no registered builder (which
|
||||
would indicate a sharable source slipped through the caller's
|
||||
``sharable`` gate, or a new kind missing from this registry).
|
||||
"""
|
||||
builder = STREAM_BUILDERS.get(source.source_type)
|
||||
if builder is None:
|
||||
raise ValueError(
|
||||
f"No stream builder for non-sharable color-strip-source kind "
|
||||
f"{source.source_type!r} (id={getattr(source, 'id', '?')!r})"
|
||||
)
|
||||
return builder(source, deps)
|
||||
|
||||
|
||||
def _assert_stream_kind_coverage() -> None:
|
||||
"""Verify the registry is a strict partition: every kind from storage is
|
||||
either listed in SHARABLE_KINDS or has a STREAM_BUILDERS entry.
|
||||
|
||||
Runs at module import so a kind added to ``_SOURCE_TYPE_MAP`` without a
|
||||
corresponding builder fails the server boot loudly instead of silently
|
||||
falling through at request time.
|
||||
|
||||
Contract note
|
||||
-------------
|
||||
This check is **asymmetric** (``STREAM_BUILDERS ∪ SHARABLE_KINDS ==
|
||||
storage_kinds``) because sharable kinds are constructed by a separate
|
||||
path inside ``ColorStripStreamManager``. The sister assertion in
|
||||
``api/routes/color_strip_sources/_helpers.py::_assert_response_map_coverage``
|
||||
is **symmetric** (``_RESPONSE_MAP keys == storage_kinds``) because every
|
||||
kind, sharable or not, still needs a response shape. Both assertions key
|
||||
by the ``source_type`` string; adding a new kind requires updates to
|
||||
storage, ``_RESPONSE_MAP``, and either ``STREAM_BUILDERS`` or
|
||||
``SHARABLE_KINDS``. Both assertions catch missing entries; only this one
|
||||
expects a subset relationship.
|
||||
"""
|
||||
from ledgrab.storage.color_strip_source import _SOURCE_TYPE_MAP
|
||||
|
||||
storage_kinds = set(_SOURCE_TYPE_MAP.keys())
|
||||
builder_kinds = set(STREAM_BUILDERS.keys())
|
||||
expected_non_sharable = storage_kinds - SHARABLE_KINDS
|
||||
|
||||
missing = expected_non_sharable - builder_kinds
|
||||
extra = builder_kinds - storage_kinds
|
||||
if missing or extra:
|
||||
problems = []
|
||||
if missing:
|
||||
problems.append(f"missing builders: {sorted(missing)}")
|
||||
if extra:
|
||||
problems.append(f"unregistered kinds: {sorted(extra)}")
|
||||
raise RuntimeError(
|
||||
"color_strip_kinds.STREAM_BUILDERS is out of sync with storage._SOURCE_TYPE_MAP: "
|
||||
+ "; ".join(problems)
|
||||
)
|
||||
|
||||
|
||||
_assert_stream_kind_coverage()
|
||||
@@ -9,7 +9,7 @@ from ledgrab.core.processing.color_strip import ( # noqa: F401
|
||||
ColorStripStream,
|
||||
GradientColorStripStream,
|
||||
PictureColorStripStream,
|
||||
StaticColorStripStream,
|
||||
SingleColorStripStream,
|
||||
_compute_gradient_colors,
|
||||
_gradient_noise,
|
||||
_SimpleNoise1D,
|
||||
@@ -18,7 +18,7 @@ from ledgrab.core.processing.color_strip import ( # noqa: F401
|
||||
__all__ = [
|
||||
"ColorStripStream",
|
||||
"PictureColorStripStream",
|
||||
"StaticColorStripStream",
|
||||
"SingleColorStripStream",
|
||||
"GradientColorStripStream",
|
||||
"_compute_gradient_colors",
|
||||
"_SimpleNoise1D",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
PictureColorStripStreams (expensive screen capture) are shared across multiple
|
||||
consumers via reference counting — processing runs once, not once per target.
|
||||
|
||||
Count-dependent streams (static, gradient, effect) are NOT shared.
|
||||
Count-dependent streams (single_color, gradient, effect) are NOT shared.
|
||||
Each consumer gets its own instance so it can configure an independent LED count
|
||||
without interfering with other targets.
|
||||
"""
|
||||
@@ -11,37 +11,18 @@ without interfering with other targets.
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional
|
||||
|
||||
from ledgrab.core.processing.color_strip_kinds import (
|
||||
StreamDeps,
|
||||
build_stream,
|
||||
)
|
||||
from ledgrab.core.processing.color_strip_stream import (
|
||||
ColorStripStream,
|
||||
GradientColorStripStream,
|
||||
PictureColorStripStream,
|
||||
StaticColorStripStream,
|
||||
)
|
||||
from ledgrab.core.processing.processed_stream import ProcessedColorStripStream
|
||||
from ledgrab.core.processing.effect_stream import EffectColorStripStream
|
||||
from ledgrab.core.processing.api_input_stream import ApiInputColorStripStream
|
||||
from ledgrab.core.processing.notification_stream import NotificationColorStripStream
|
||||
from ledgrab.core.processing.daylight_stream import DaylightColorStripStream
|
||||
from ledgrab.core.processing.candlelight_stream import CandlelightColorStripStream
|
||||
from ledgrab.core.processing.game_event_stream import GameEventColorStripStream
|
||||
from ledgrab.core.processing.math_wave_stream import MathWaveColorStripStream
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# source_type → stream class for non-picture (non-sharable) sources
|
||||
_SIMPLE_STREAM_MAP = {
|
||||
"static": StaticColorStripStream,
|
||||
"gradient": GradientColorStripStream,
|
||||
"effect": EffectColorStripStream,
|
||||
"api_input": ApiInputColorStripStream,
|
||||
"notification": NotificationColorStripStream,
|
||||
"daylight": DaylightColorStripStream,
|
||||
"candlelight": CandlelightColorStripStream,
|
||||
"game_event": GameEventColorStripStream,
|
||||
"math_wave": MathWaveColorStripStream,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ColorStripEntry:
|
||||
@@ -241,43 +222,27 @@ class ColorStripStreamManager:
|
||||
"""
|
||||
source = self._color_strip_store.get_source(css_id)
|
||||
|
||||
# Non-sharable: always create a fresh per-consumer instance
|
||||
# Non-sharable: always create a fresh per-consumer instance.
|
||||
# Construction is delegated to the per-kind registry in
|
||||
# ``color_strip_kinds`` so the dispatch lives in exactly one place.
|
||||
if not source.sharable:
|
||||
if source.source_type == "audio":
|
||||
from ledgrab.core.processing.audio_stream import AudioColorStripStream
|
||||
|
||||
css_stream = AudioColorStripStream(
|
||||
source,
|
||||
self._audio_capture_manager,
|
||||
self._audio_source_store,
|
||||
self._audio_template_store,
|
||||
self._audio_processing_template_store,
|
||||
)
|
||||
elif source.source_type == "composite":
|
||||
from ledgrab.core.processing.composite_stream import (
|
||||
CompositeColorStripStream,
|
||||
)
|
||||
|
||||
css_stream = CompositeColorStripStream(
|
||||
source, self, self._value_stream_manager, self._cspt_store, depth=depth
|
||||
)
|
||||
elif source.source_type == "mapped":
|
||||
from ledgrab.core.processing.mapped_stream import MappedColorStripStream
|
||||
|
||||
css_stream = MappedColorStripStream(source, self)
|
||||
elif source.source_type == "processed":
|
||||
css_stream = ProcessedColorStripStream(source, self, self._cspt_store)
|
||||
elif source.source_type == "weather":
|
||||
from ledgrab.core.processing.weather_stream import WeatherColorStripStream
|
||||
|
||||
css_stream = WeatherColorStripStream(source, self._weather_manager)
|
||||
else:
|
||||
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
|
||||
if not stream_cls:
|
||||
raise ValueError(
|
||||
f"Unsupported color strip source type '{source.source_type}' for {css_id}"
|
||||
)
|
||||
css_stream = stream_cls(source)
|
||||
deps = StreamDeps(
|
||||
css_manager=self,
|
||||
value_stream_manager=self._value_stream_manager,
|
||||
cspt_store=self._cspt_store,
|
||||
weather_manager=self._weather_manager,
|
||||
audio_capture_manager=self._audio_capture_manager,
|
||||
audio_source_store=self._audio_source_store,
|
||||
audio_template_store=self._audio_template_store,
|
||||
audio_processing_template_store=self._audio_processing_template_store,
|
||||
game_event_bus=self._game_event_bus,
|
||||
depth=depth,
|
||||
)
|
||||
try:
|
||||
css_stream = build_stream(source, deps)
|
||||
except ValueError as e:
|
||||
# Surface the css_id alongside the registry's error.
|
||||
raise ValueError(f"{e} (css_id={css_id})") from e
|
||||
# Inject gradient store for palette resolution
|
||||
if self._gradient_store and hasattr(css_stream, "set_gradient_store"):
|
||||
css_stream.set_gradient_store(self._gradient_store)
|
||||
|
||||
@@ -91,7 +91,10 @@ class DeviceTestModeMixin:
|
||||
led_count=ds.led_count,
|
||||
use_ddp=True,
|
||||
)
|
||||
deps = ProviderDeps(device_store=self._device_store)
|
||||
deps = ProviderDeps(
|
||||
device_store=self._device_store,
|
||||
mqtt_manager=getattr(self, "_mqtt_manager", None),
|
||||
)
|
||||
client = create_led_client(config, deps=deps)
|
||||
await client.connect()
|
||||
self._idle_clients[device_id] = client
|
||||
|
||||
@@ -11,7 +11,7 @@ no external dependencies are required.
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Optional
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -22,6 +22,54 @@ from ledgrab.utils.timer import high_resolution_timer
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# -- Effect renderer registry --
|
||||
# A small attribute-marker + class-decorator pair: the per-method
|
||||
# ``@_effect_renderer("fire")`` decorator stamps a name onto the unbound
|
||||
# method; ``_collect_effect_renderers`` (applied to the class) scans those
|
||||
# stamps and builds a ``cls._RENDERERS`` dict. This replaces the inline
|
||||
# ``renderers = {"fire": self._render_fire, ...}`` dict that the animation
|
||||
# loop used to rebuild every frame, and the silent ``.get(..., self._render_fire)``
|
||||
# fallback that turned a typo in ``_effect_type`` into a hidden fire-renderer.
|
||||
|
||||
|
||||
def _effect_renderer(name: str) -> Callable:
|
||||
"""Mark a method as the renderer for the given effect type.
|
||||
|
||||
The actual collection happens via ``@_collect_effect_renderers`` on the
|
||||
enclosing class — decorating the method alone is harmless if the class
|
||||
decorator is omitted.
|
||||
"""
|
||||
|
||||
def _decorator(method):
|
||||
method._effect_renderer_name = name
|
||||
return method
|
||||
|
||||
return _decorator
|
||||
|
||||
|
||||
def _collect_effect_renderers(cls):
|
||||
"""Class decorator: gather methods tagged with ``@_effect_renderer``
|
||||
into ``cls._RENDERERS``.
|
||||
|
||||
Runs once at class-creation. Raises ``RuntimeError`` if two methods
|
||||
register under the same name (catches copy-paste typos).
|
||||
"""
|
||||
renderers: Dict[str, Callable] = {}
|
||||
for attr_name in dir(cls):
|
||||
member = getattr(cls, attr_name, None)
|
||||
effect_name = getattr(member, "_effect_renderer_name", None)
|
||||
if effect_name is None:
|
||||
continue
|
||||
if effect_name in renderers:
|
||||
raise RuntimeError(
|
||||
f"Duplicate @_effect_renderer({effect_name!r}) registration on {cls.__name__}"
|
||||
)
|
||||
renderers[effect_name] = member
|
||||
cls._RENDERERS = renderers
|
||||
return cls
|
||||
|
||||
|
||||
# ── Palette LUT system ──────────────────────────────────────────────────
|
||||
|
||||
# Each palette is a list of (position, R, G, B) control points.
|
||||
@@ -211,13 +259,14 @@ _EFFECT_DEFAULT_PALETTE = {
|
||||
}
|
||||
|
||||
|
||||
@_collect_effect_renderers
|
||||
class EffectColorStripStream(ColorStripStream):
|
||||
"""Color strip stream that runs a procedural LED effect.
|
||||
|
||||
Dispatches to one of five render methods based on effect_type:
|
||||
fire, meteor, plasma, noise, aurora.
|
||||
|
||||
Uses the same lifecycle pattern as StaticColorStripStream:
|
||||
Uses the same lifecycle pattern as SingleColorStripStream:
|
||||
background thread, double-buffered output, configure() for auto-sizing.
|
||||
"""
|
||||
|
||||
@@ -367,21 +416,10 @@ class EffectColorStripStream(ColorStripStream):
|
||||
_buf_a = _buf_b = None
|
||||
_use_a = True
|
||||
|
||||
# Dispatch table
|
||||
renderers = {
|
||||
"fire": self._render_fire,
|
||||
"meteor": self._render_meteor,
|
||||
"plasma": self._render_plasma,
|
||||
"noise": self._render_noise,
|
||||
"aurora": self._render_aurora,
|
||||
"rain": self._render_rain,
|
||||
"comet": self._render_comet,
|
||||
"bouncing_ball": self._render_bouncing_ball,
|
||||
"fireworks": self._render_fireworks,
|
||||
"sparkle_rain": self._render_sparkle_rain,
|
||||
"lava_lamp": self._render_lava_lamp,
|
||||
"wave_interference": self._render_wave_interference,
|
||||
}
|
||||
# Dispatch via the class-level registry built by
|
||||
# ``@_collect_effect_renderers`` at class-creation time. Renderers
|
||||
# are unbound methods, so each call passes ``self`` explicitly.
|
||||
renderers = type(self)._RENDERERS
|
||||
|
||||
limiter = FrameLimiter(self._fps)
|
||||
|
||||
@@ -424,8 +462,19 @@ class EffectColorStripStream(ColorStripStream):
|
||||
buf = _buf_a if _use_a else _buf_b
|
||||
_use_a = not _use_a
|
||||
|
||||
render_fn = renderers.get(self._effect_type, self._render_fire)
|
||||
render_fn(buf, n, anim_time)
|
||||
render_fn = renderers.get(self._effect_type)
|
||||
if render_fn is None:
|
||||
# Unknown effect type — log once per loop pass and
|
||||
# skip rendering rather than silently falling back
|
||||
# to fire (the previous behaviour, which hid typos
|
||||
# in ``_effect_type``).
|
||||
logger.warning(
|
||||
"EffectColorStripStream: unknown effect_type %r — skipping frame",
|
||||
self._effect_type,
|
||||
)
|
||||
time.sleep(frame_time)
|
||||
continue
|
||||
render_fn(self, buf, n, anim_time)
|
||||
|
||||
with self._colors_lock:
|
||||
self._colors = buf
|
||||
@@ -440,6 +489,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
|
||||
# ── Fire ─────────────────────────────────────────────────────────
|
||||
|
||||
@_effect_renderer("fire")
|
||||
def _render_fire(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Heat-propagation fire simulation.
|
||||
|
||||
@@ -490,6 +540,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
|
||||
# ── Meteor ───────────────────────────────────────────────────────
|
||||
|
||||
@_effect_renderer("meteor")
|
||||
def _render_meteor(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Bright meteor head with exponential-decay trail."""
|
||||
speed = self._effective_speed
|
||||
@@ -560,6 +611,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
|
||||
# ── Plasma ───────────────────────────────────────────────────────
|
||||
|
||||
@_effect_renderer("plasma")
|
||||
def _render_plasma(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Overlapping sine waves creating colorful plasma patterns."""
|
||||
speed = self._effective_speed
|
||||
@@ -587,6 +639,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
|
||||
# ── Perlin Noise ─────────────────────────────────────────────────
|
||||
|
||||
@_effect_renderer("noise")
|
||||
def _render_noise(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Smooth scrolling fractal noise mapped to a color palette."""
|
||||
speed = self._effective_speed
|
||||
@@ -605,6 +658,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
|
||||
# ── Aurora ───────────────────────────────────────────────────────
|
||||
|
||||
@_effect_renderer("aurora")
|
||||
def _render_aurora(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Layered noise bands simulating aurora borealis."""
|
||||
speed = self._effective_speed
|
||||
@@ -651,6 +705,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
|
||||
# ── Rain ──────────────────────────────────────────────────────────
|
||||
|
||||
@_effect_renderer("rain")
|
||||
def _render_rain(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Raindrops falling down the strip with trailing tails."""
|
||||
speed = self._effective_speed
|
||||
@@ -686,6 +741,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
|
||||
# ── Comet ─────────────────────────────────────────────────────────
|
||||
|
||||
@_effect_renderer("comet")
|
||||
def _render_comet(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Multiple comets with curved, pulsing tails."""
|
||||
speed = self._effective_speed
|
||||
@@ -732,6 +788,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
|
||||
# ── Bouncing Ball ─────────────────────────────────────────────────
|
||||
|
||||
@_effect_renderer("bouncing_ball")
|
||||
def _render_bouncing_ball(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Physics-simulated bouncing balls with gravity."""
|
||||
speed = self._effective_speed
|
||||
@@ -795,6 +852,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
|
||||
# ── Fireworks ─────────────────────────────────────────────────────
|
||||
|
||||
@_effect_renderer("fireworks")
|
||||
def _render_fireworks(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Rockets launch and explode into colorful particle bursts."""
|
||||
speed = self._effective_speed
|
||||
@@ -868,6 +926,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
|
||||
# ── Sparkle Rain ──────────────────────────────────────────────────
|
||||
|
||||
@_effect_renderer("sparkle_rain")
|
||||
def _render_sparkle_rain(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Twinkling star field with smooth fade-in/fade-out."""
|
||||
speed = self._effective_speed
|
||||
@@ -904,6 +963,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
|
||||
# ── Lava Lamp ─────────────────────────────────────────────────────
|
||||
|
||||
@_effect_renderer("lava_lamp")
|
||||
def _render_lava_lamp(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Slow-moving colored blobs that merge and separate."""
|
||||
speed = self._effective_speed
|
||||
@@ -945,6 +1005,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
|
||||
# ── Wave Interference ─────────────────────────────────────────────
|
||||
|
||||
@_effect_renderer("wave_interference")
|
||||
def _render_wave_interference(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Two counter-propagating sine waves creating interference patterns."""
|
||||
speed = self._effective_speed
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.processing.light_target_helpers import swap_color_source
|
||||
from ledgrab.core.processing.target_processor import TargetContext, TargetProcessor
|
||||
from ledgrab.storage.ha_light_output_target import HALightMapping
|
||||
from ledgrab.utils import get_logger
|
||||
@@ -255,50 +256,11 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
|
||||
def _swap_color_source(self, new_kind: str, new_color_vs_id: str) -> None:
|
||||
"""Release the previous colour stream and acquire the new one."""
|
||||
# Tear down previous stream first to keep ref-counts honest.
|
||||
if self._is_running:
|
||||
if self._css_stream and self._ctx.color_strip_stream_manager:
|
||||
try:
|
||||
self._ctx.color_strip_stream_manager.release(self._css_id, self._target_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._css_stream = None
|
||||
if self._color_stream is not None and self._ctx.value_stream_manager:
|
||||
try:
|
||||
self._ctx.value_stream_manager.release(self._color_vs_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._color_stream = None
|
||||
|
||||
self._source_kind = new_kind
|
||||
self._color_vs_id = new_color_vs_id
|
||||
|
||||
swap_color_source(self, new_kind, new_color_vs_id, log_label="HA light")
|
||||
# Reset per-entity history so the new source isn't gated by stale values.
|
||||
self._previous_colors.clear()
|
||||
self._previous_on.clear()
|
||||
|
||||
if not self._is_running:
|
||||
return
|
||||
|
||||
if self._source_kind == "color_vs":
|
||||
if self._color_vs_id and self._ctx.value_stream_manager:
|
||||
try:
|
||||
self._color_stream = self._ctx.value_stream_manager.acquire(self._color_vs_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"HA light {self._target_id}: failed to acquire color VS stream: {e}"
|
||||
)
|
||||
else:
|
||||
if self._css_id and self._ctx.color_strip_stream_manager:
|
||||
try:
|
||||
self._css_stream = self._ctx.color_strip_stream_manager.acquire(
|
||||
self._css_id, self._target_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"HA light {self._target_id}: failed to re-acquire CSS stream: {e}"
|
||||
)
|
||||
|
||||
# ── WebSocket clients ──
|
||||
|
||||
def add_ws_client(self, ws: Any) -> None:
|
||||
@@ -400,17 +362,16 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
# Cache for WS preview (always, even if HA call is skipped)
|
||||
self._latest_entity_colors[entity_id] = (r, g, b)
|
||||
|
||||
# Calculate brightness (0-255) from max channel
|
||||
brightness = max(r, g, b)
|
||||
|
||||
# Brightness (0-255) is derived from the max channel and scaled once by
|
||||
# the per-mapping brightness_scale × value-source multiplier. eff_scale
|
||||
# may exceed 1.0 (a boosting value source), so we clamp at 255.
|
||||
bs = (
|
||||
mapping.brightness_scale.value
|
||||
if hasattr(mapping.brightness_scale, "value")
|
||||
else mapping.brightness_scale
|
||||
)
|
||||
eff_scale = bs * vs_multiplier
|
||||
if eff_scale < 1.0:
|
||||
brightness = int(brightness * eff_scale)
|
||||
brightness = max(0, min(255, int(max(r, g, b) * eff_scale)))
|
||||
|
||||
should_be_on = (
|
||||
brightness >= self._min_brightness_threshold or self._min_brightness_threshold == 0
|
||||
@@ -430,7 +391,7 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
|
||||
service_data = {
|
||||
"rgb_color": [r, g, b],
|
||||
"brightness": min(255, int(brightness * bs)),
|
||||
"brightness": brightness,
|
||||
}
|
||||
transition_val = self._transition.value
|
||||
if transition_val > 0:
|
||||
@@ -523,6 +484,59 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
snap[eid] = state
|
||||
return snap
|
||||
|
||||
def _unique_mapped_entity_ids(self) -> List[str]:
|
||||
"""Return unique non-empty entity ids from configured mappings."""
|
||||
entity_ids: List[str] = []
|
||||
seen = set()
|
||||
for mapping in self._light_mappings:
|
||||
eid = mapping.entity_id
|
||||
if eid and eid not in seen:
|
||||
seen.add(eid)
|
||||
entity_ids.append(eid)
|
||||
return entity_ids
|
||||
|
||||
async def turn_off_lights(self) -> int:
|
||||
"""Turn off every mapped HA light entity.
|
||||
|
||||
Works regardless of whether the processor is currently running.
|
||||
If the HA runtime isn't already acquired, temporarily acquires it
|
||||
from the manager so the call can succeed without starting the
|
||||
target. Returns the number of entities the turn_off was issued for.
|
||||
"""
|
||||
entity_ids = self._unique_mapped_entity_ids()
|
||||
if not entity_ids:
|
||||
return 0
|
||||
|
||||
# Use existing runtime when running; otherwise borrow one from the
|
||||
# manager via acquire/release so we don't keep a connection open.
|
||||
ha_manager = getattr(self._ctx, "ha_manager", None)
|
||||
runtime = self._ha_runtime
|
||||
borrowed = False
|
||||
if runtime is None:
|
||||
if ha_manager is None or not self._ha_source_id:
|
||||
raise RuntimeError("HA runtime not available")
|
||||
runtime = await ha_manager.acquire(self._ha_source_id)
|
||||
borrowed = True
|
||||
|
||||
try:
|
||||
if not runtime.is_connected:
|
||||
raise RuntimeError("HA not connected")
|
||||
for eid in entity_ids:
|
||||
await runtime.call_service(
|
||||
domain="light",
|
||||
service="turn_off",
|
||||
service_data={},
|
||||
target={"entity_id": eid},
|
||||
)
|
||||
finally:
|
||||
if borrowed and ha_manager is not None:
|
||||
try:
|
||||
await ha_manager.release(self._ha_source_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return len(entity_ids)
|
||||
|
||||
async def _apply_stop_action(self) -> None:
|
||||
"""Run the configured finalization on stop."""
|
||||
if self._stop_action == "none":
|
||||
@@ -534,15 +548,7 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
)
|
||||
return
|
||||
|
||||
# Unique entity ids (a target may map the same entity twice in theory)
|
||||
entity_ids = []
|
||||
seen = set()
|
||||
for mapping in self._light_mappings:
|
||||
eid = mapping.entity_id
|
||||
if eid and eid not in seen:
|
||||
seen.add(eid)
|
||||
entity_ids.append(eid)
|
||||
|
||||
entity_ids = self._unique_mapped_entity_ids()
|
||||
if not entity_ids:
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Shared helpers for HA / Zigbee2MQTT light target processors.
|
||||
|
||||
``HALightTargetProcessor`` and ``Z2MLightTargetProcessor`` historically
|
||||
duplicated their colour-source swap logic character-for-character — only
|
||||
the log prefix and a docstring differed (audit finding C5). This module
|
||||
hosts the deduplicated implementation.
|
||||
|
||||
We deliberately stop short of extracting a full ``BaseLightTargetProcessor``
|
||||
ABC here: the read sites for the per-processor state are spread across ~38
|
||||
locations per file and a wholesale composition refactor risks regressing
|
||||
the live LED control loop. The free-function approach below is the
|
||||
minimum-blast-radius way to delete the duplication. The processor still
|
||||
owns its state; the helper reaches in to mutate it, which is ugly but
|
||||
Pythonic and isolated to two methods.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# ``LightTargetSwapState`` is a structural protocol — anything carrying
|
||||
# the listed attributes is acceptable. We do not import the concrete
|
||||
# processor classes to avoid a circular dependency.
|
||||
from typing import Any, Protocol
|
||||
|
||||
class LightTargetSwapState(Protocol):
|
||||
_is_running: bool
|
||||
_css_stream: Any
|
||||
_color_stream: Any
|
||||
_ctx: Any
|
||||
_css_id: str
|
||||
_color_vs_id: str
|
||||
_source_kind: str
|
||||
_target_id: str
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def swap_color_source(
|
||||
processor: "LightTargetSwapState",
|
||||
new_kind: str,
|
||||
new_color_vs_id: str,
|
||||
*,
|
||||
log_label: str,
|
||||
) -> None:
|
||||
"""Release the current colour source and acquire the new one.
|
||||
|
||||
Mirrors what ``HALightTargetProcessor._swap_color_source`` and
|
||||
``Z2MLightTargetProcessor._swap_color_source`` used to do inline.
|
||||
|
||||
The caller is responsible for clearing per-entity history
|
||||
(``_previous_colors``, ``_previous_on``) after this returns —
|
||||
that state is owned by the processor, not the colour source.
|
||||
|
||||
``log_label`` is the short identifier used in warning logs
|
||||
(e.g. ``"HA light"`` or ``"Z2M light"``) so a failure is
|
||||
traceable back to the right processor in mixed deployments.
|
||||
"""
|
||||
# Tear down the previously-acquired stream first to keep ref-counts honest.
|
||||
if processor._is_running:
|
||||
if processor._css_stream and processor._ctx.color_strip_stream_manager:
|
||||
try:
|
||||
processor._ctx.color_strip_stream_manager.release(
|
||||
processor._css_id, processor._target_id
|
||||
)
|
||||
except Exception:
|
||||
# Manager-level errors are non-fatal: stream may already be
|
||||
# gone if the source was deleted out from under us.
|
||||
pass
|
||||
processor._css_stream = None
|
||||
if processor._color_stream is not None and processor._ctx.value_stream_manager:
|
||||
try:
|
||||
processor._ctx.value_stream_manager.release(processor._color_vs_id)
|
||||
except Exception:
|
||||
pass
|
||||
processor._color_stream = None
|
||||
|
||||
processor._source_kind = new_kind
|
||||
processor._color_vs_id = new_color_vs_id
|
||||
|
||||
if not processor._is_running:
|
||||
# Not started yet; the start() path will acquire when called.
|
||||
return
|
||||
|
||||
if processor._source_kind == "color_vs":
|
||||
if processor._color_vs_id and processor._ctx.value_stream_manager:
|
||||
try:
|
||||
processor._color_stream = processor._ctx.value_stream_manager.acquire(
|
||||
processor._color_vs_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"%s %s: failed to acquire color VS stream: %s",
|
||||
log_label,
|
||||
processor._target_id,
|
||||
e,
|
||||
)
|
||||
else:
|
||||
if processor._css_id and processor._ctx.color_strip_stream_manager:
|
||||
try:
|
||||
processor._css_stream = processor._ctx.color_strip_stream_manager.acquire(
|
||||
processor._css_id, processor._target_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"%s %s: failed to re-acquire CSS stream: %s",
|
||||
log_label,
|
||||
processor._target_id,
|
||||
e,
|
||||
)
|
||||
@@ -31,9 +31,17 @@ class LiveStream(ABC):
|
||||
"""Abstract base for a runtime frame source.
|
||||
|
||||
A LiveStream produces frames at some frequency. Consumers call
|
||||
get_latest_frame() to read the most recent frame (non-blocking).
|
||||
get_latest_frame() to read the most recent frame (non-blocking),
|
||||
or wait_for_new_frame() for event-driven consumption.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Monotonic frame counter incremented by ``_signal_new_frame``.
|
||||
# Consumers track the last value they saw to detect new frames
|
||||
# without busy-polling.
|
||||
self._frame_id: int = 0
|
||||
self._frame_cond: threading.Condition = threading.Condition()
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def target_fps(self) -> int:
|
||||
@@ -60,6 +68,35 @@ class LiveStream(ABC):
|
||||
ScreenCapture with image data (RGB), or None if no frame available yet.
|
||||
"""
|
||||
|
||||
@property
|
||||
def current_frame_id(self) -> int:
|
||||
"""Current monotonic frame ID. Increments on each new frame."""
|
||||
return self._frame_id
|
||||
|
||||
def _signal_new_frame(self) -> None:
|
||||
"""Producer: increment frame ID and notify all waiting consumers."""
|
||||
with self._frame_cond:
|
||||
self._frame_id += 1
|
||||
self._frame_cond.notify_all()
|
||||
|
||||
def wait_for_new_frame(self, last_seen_id: int, timeout: float) -> int:
|
||||
"""Consumer: block until a frame newer than ``last_seen_id`` arrives.
|
||||
|
||||
Args:
|
||||
last_seen_id: Last frame ID the caller has already processed.
|
||||
timeout: Maximum seconds to wait. ``0`` returns immediately.
|
||||
|
||||
Returns:
|
||||
Current ``frame_id`` (may equal ``last_seen_id`` if timed out).
|
||||
"""
|
||||
if timeout <= 0:
|
||||
return self._frame_id
|
||||
with self._frame_cond:
|
||||
if self._frame_id != last_seen_id:
|
||||
return self._frame_id
|
||||
self._frame_cond.wait(timeout=timeout)
|
||||
return self._frame_id
|
||||
|
||||
|
||||
class ScreenCaptureLiveStream(LiveStream):
|
||||
"""Live stream backed by a CaptureStream with a dedicated capture thread.
|
||||
@@ -73,6 +110,7 @@ class ScreenCaptureLiveStream(LiveStream):
|
||||
"""
|
||||
|
||||
def __init__(self, capture_stream: CaptureStream, fps: int):
|
||||
super().__init__()
|
||||
self._capture_stream = capture_stream
|
||||
self._fps = fps
|
||||
self._frame_time = 1.0 / fps if fps > 0 else 1.0
|
||||
@@ -139,6 +177,7 @@ class ScreenCaptureLiveStream(LiveStream):
|
||||
if frame is not None:
|
||||
with self._frame_lock:
|
||||
self._latest_frame = frame
|
||||
self._signal_new_frame()
|
||||
consecutive_errors = 0
|
||||
else:
|
||||
# Small sleep when no frame available to avoid CPU spinning
|
||||
@@ -181,6 +220,7 @@ class ProcessedLiveStream(LiveStream):
|
||||
source: LiveStream,
|
||||
filters: List[PostprocessingFilter],
|
||||
):
|
||||
super().__init__()
|
||||
self._source = source
|
||||
self._filters = filters
|
||||
self._image_pool = ImagePool()
|
||||
@@ -234,15 +274,22 @@ class ProcessedLiveStream(LiveStream):
|
||||
def _process_loop(self) -> None:
|
||||
"""Background thread: poll source, apply filters, cache result."""
|
||||
cached_source_frame: Optional[ScreenCapture] = None
|
||||
# Ring buffer: 3 slots guarantees consumer finished with oldest buffer.
|
||||
# At most 2 frames are in flight (one in _latest_frame, one being
|
||||
# processed by a consumer), so the 3rd slot is always safe to reuse.
|
||||
_ring: List[Optional[np.ndarray]] = [None, None, None]
|
||||
# Ring buffer: 5 slots gives a safety margin for the multi-consumer
|
||||
# case (multiple PictureColorStripStream/HA target threads can hold
|
||||
# the same _latest_frame reference while we wrap around). At 60 FPS
|
||||
# the 5-slot rotation gives any consumer ~83 ms to finish reading
|
||||
# before the slot is overwritten — well above any realistic
|
||||
# extract→map→smooth tick.
|
||||
_RING_SIZE = 5
|
||||
_ring: List[Optional[np.ndarray]] = [None] * _RING_SIZE
|
||||
_ring_idx = 0
|
||||
# Separate buffer for idle-tick source copies (not part of the ring buffer)
|
||||
_idle_src_buf: Optional[np.ndarray] = None
|
||||
fps = self.target_fps
|
||||
frame_time = 1.0 / fps if fps > 0 else 1.0
|
||||
# Track the source's frame_id so we can wait event-driven for new
|
||||
# frames instead of polling + sleeping.
|
||||
last_source_id = self._source.current_frame_id
|
||||
|
||||
try:
|
||||
with high_resolution_timer():
|
||||
@@ -278,13 +325,20 @@ class ProcessedLiveStream(LiveStream):
|
||||
)
|
||||
with self._frame_lock:
|
||||
self._latest_frame = processed
|
||||
self._signal_new_frame()
|
||||
|
||||
# Event-driven wait: blocks until source produces a
|
||||
# new frame, with frame_time as a safety timeout.
|
||||
elapsed = time.perf_counter() - loop_start
|
||||
remaining = frame_time - elapsed
|
||||
time.sleep(max(remaining, 0.001))
|
||||
remaining = max(frame_time - elapsed, 0.0)
|
||||
if remaining > 0:
|
||||
last_source_id = self._source.wait_for_new_frame(
|
||||
last_source_id, timeout=remaining
|
||||
)
|
||||
continue
|
||||
|
||||
cached_source_frame = source_frame
|
||||
last_source_id = self._source.current_frame_id
|
||||
|
||||
# Reuse ring buffer slot instead of allocating a new copy each frame
|
||||
src = source_frame.image
|
||||
@@ -293,7 +347,7 @@ class ProcessedLiveStream(LiveStream):
|
||||
if buf is None or buf.shape != (h, w, c):
|
||||
buf = np.empty((h, w, c), dtype=np.uint8)
|
||||
_ring[_ring_idx] = buf
|
||||
_ring_idx = (_ring_idx + 1) % 3
|
||||
_ring_idx = (_ring_idx + 1) % _RING_SIZE
|
||||
|
||||
np.copyto(buf, src)
|
||||
image = buf
|
||||
@@ -315,6 +369,7 @@ class ProcessedLiveStream(LiveStream):
|
||||
)
|
||||
with self._frame_lock:
|
||||
self._latest_frame = processed
|
||||
self._signal_new_frame()
|
||||
except Exception as e:
|
||||
logger.error(f"Filter processing error: {e}")
|
||||
time.sleep(0.01)
|
||||
@@ -328,9 +383,12 @@ class StaticImageLiveStream(LiveStream):
|
||||
"""Live stream that always returns the same static image."""
|
||||
|
||||
def __init__(self, image: np.ndarray):
|
||||
super().__init__()
|
||||
self._image = image
|
||||
h, w = image.shape[:2]
|
||||
self._frame = ScreenCapture(image=image, width=w, height=h, display_index=-1)
|
||||
# Bump frame_id once so consumers waiting on it see a "new" frame.
|
||||
self._signal_new_frame()
|
||||
|
||||
@property
|
||||
def target_fps(self) -> int:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
@@ -11,6 +12,11 @@ from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Cap the (src_len, dst_len) resize cache. Each entry holds two ``np.linspace``
|
||||
# arrays plus a per-zone ``uint8`` scratch buffer, which used to grow without
|
||||
# bound under hot reconfigure storms.
|
||||
_RESIZE_CACHE_MAX = 16
|
||||
|
||||
|
||||
class MappedColorStripStream(ColorStripStream):
|
||||
"""Places multiple ColorStripStreams side-by-side at distinct LED ranges.
|
||||
@@ -25,7 +31,13 @@ class MappedColorStripStream(ColorStripStream):
|
||||
"""
|
||||
|
||||
def __init__(self, source, css_manager):
|
||||
import uuid as _uuid
|
||||
|
||||
self._source_id: str = source.id
|
||||
# Unique per instance so concurrent consumers don't collide on
|
||||
# sub-stream consumer IDs (e.g. two preview WS connections against
|
||||
# the same mapped source, or future fan-out to multiple targets).
|
||||
self._instance_id: str = _uuid.uuid4().hex[:8]
|
||||
self._zones: List[dict] = list(source.zones)
|
||||
self._led_count: int = source.led_count
|
||||
self._auto_size: bool = source.led_count == 0
|
||||
@@ -40,8 +52,11 @@ class MappedColorStripStream(ColorStripStream):
|
||||
|
||||
# zone_index -> (source_id, consumer_id, stream)
|
||||
self._sub_streams: Dict[int, tuple] = {}
|
||||
# (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing
|
||||
self._resize_cache: Dict[tuple, tuple] = {}
|
||||
# (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing.
|
||||
# An ``OrderedDict`` with eviction keeps memory bounded if device
|
||||
# configurations fluctuate at runtime (each unique pair adds two
|
||||
# linspace arrays + a per-zone reusable uint8 buffer).
|
||||
self._resize_cache: "OrderedDict[tuple, tuple]" = OrderedDict()
|
||||
self._sub_lock = threading.Lock() # guards _sub_streams access across threads
|
||||
|
||||
# ── ColorStripStream interface ──────────────────────────────
|
||||
@@ -127,7 +142,7 @@ class MappedColorStripStream(ColorStripStream):
|
||||
src_id = zone.get("source_id", "")
|
||||
if not src_id:
|
||||
continue
|
||||
consumer_id = f"{self._source_id}__zone_{i}"
|
||||
consumer_id = f"{self._source_id}__{self._instance_id}__zone_{i}"
|
||||
try:
|
||||
stream = self._css_manager.acquire(src_id, consumer_id)
|
||||
zone_len = self._zone_length(zone)
|
||||
@@ -223,6 +238,14 @@ class MappedColorStripStream(ColorStripStream):
|
||||
np.empty((zone_len, 3), dtype=np.uint8),
|
||||
)
|
||||
self._resize_cache[rkey] = cached
|
||||
# Drop the least-recently-inserted entry once
|
||||
# we hit the cap. 16 entries comfortably covers
|
||||
# any realistic zone/source layout — pathological
|
||||
# reconfigure storms used to grow this forever.
|
||||
if len(self._resize_cache) > _RESIZE_CACHE_MAX:
|
||||
self._resize_cache.popitem(last=False)
|
||||
else:
|
||||
self._resize_cache.move_to_end(rkey)
|
||||
src_x, dst_x, resized = cached
|
||||
for ch in range(3):
|
||||
np.copyto(
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Per-metric reader / normaliser registry for ``SystemMetricsValueStream``.
|
||||
|
||||
The stream previously dispatched on a ``self._metric`` string across three
|
||||
separate ``if / elif`` chains (priming in ``start()``, raw read in
|
||||
``_read_metric_psutil`` + ``_read_metric_fallback``, normalisation in
|
||||
``_normalize``). Adding a new metric meant editing every chain.
|
||||
|
||||
This module replaces all of that with a single ``METRIC_SPECS`` dict keyed
|
||||
by metric name. Each :class:`MetricSpec` declares:
|
||||
|
||||
* ``read_psutil(stream)`` — the desktop path that uses ``stream._psutil``;
|
||||
* ``read_fallback(stream)`` — the Android / no-psutil path (returns 0.0
|
||||
for desktop-only sensors);
|
||||
* ``normalize(stream, raw)`` — maps the raw reading to ``[0, 1]``;
|
||||
* ``prime(stream)`` — optional one-time setup called from ``start()``.
|
||||
|
||||
The spec functions operate on the stream's existing attributes
|
||||
(``_disk_path``, ``_sensor_label``, ``_min_val``, ``_max_val``,
|
||||
``_max_rate``, ``_gpu_unavailable``, ``_prev_net_bytes``,
|
||||
``_prev_net_time``). That is intentional: the readers are stateless
|
||||
strategy callables, but the *stream's* state remains its own. Mutating it
|
||||
from a reader is documented per function so the contract is explicit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Callable, Dict, Optional
|
||||
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ledgrab.core.processing.value_stream import SystemMetricsValueStream
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spec dataclass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
ReaderFn = Callable[["SystemMetricsValueStream"], float]
|
||||
NormalizeFn = Callable[["SystemMetricsValueStream", float], float]
|
||||
PrimeFn = Callable[["SystemMetricsValueStream"], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetricSpec:
|
||||
"""How to read, normalise, and prime a single system metric."""
|
||||
|
||||
name: str
|
||||
read_psutil: ReaderFn
|
||||
read_fallback: ReaderFn
|
||||
normalize: NormalizeFn
|
||||
prime: Optional[PrimeFn] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Normaliser primitives — shared across metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _norm_percent(_s, raw: float) -> float:
|
||||
"""Percent metrics (cpu_load, ram_usage, …) — raw is 0..100."""
|
||||
return max(0.0, min(1.0, raw / 100.0))
|
||||
|
||||
|
||||
def _norm_range(s, raw: float) -> float:
|
||||
"""Temperature / fan-speed metrics normalise against (min_val, max_val)."""
|
||||
rng = s._max_val - s._min_val
|
||||
if abs(rng) < 1e-9:
|
||||
return 0.5
|
||||
return max(0.0, min(1.0, (raw - s._min_val) / rng))
|
||||
|
||||
|
||||
def _norm_rate(s, raw: float) -> float:
|
||||
"""Network rate normalises against ``_max_rate`` (bytes/s)."""
|
||||
if s._max_rate <= 0:
|
||||
return 0.5
|
||||
return max(0.0, min(1.0, raw / s._max_rate))
|
||||
|
||||
|
||||
def _zero(_s) -> float:
|
||||
"""Desktop-only sensor on a no-psutil platform: report 0.0."""
|
||||
return 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read helpers — psutil paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _read_cpu_load_psutil(s) -> float:
|
||||
return s._psutil.cpu_percent(interval=None)
|
||||
|
||||
|
||||
def _read_ram_usage_psutil(s) -> float:
|
||||
return s._psutil.virtual_memory().percent
|
||||
|
||||
|
||||
def _read_disk_usage_psutil(s) -> float:
|
||||
return s._psutil.disk_usage(s._disk_path).percent
|
||||
|
||||
|
||||
def _read_battery_psutil(s) -> float:
|
||||
bat = s._psutil.sensors_battery()
|
||||
return bat.percent if bat else 0.0
|
||||
|
||||
|
||||
def _read_cpu_temp_psutil(s) -> float:
|
||||
psutil = s._psutil
|
||||
if psutil is None:
|
||||
return 0.0
|
||||
temps = psutil.sensors_temperatures()
|
||||
if not temps:
|
||||
return 0.0
|
||||
if s._sensor_label:
|
||||
for group_name, entries in temps.items():
|
||||
for entry in entries:
|
||||
if entry.label == s._sensor_label or group_name == s._sensor_label:
|
||||
return entry.current
|
||||
for entries in temps.values():
|
||||
if entries:
|
||||
return entries[0].current
|
||||
return 0.0
|
||||
|
||||
|
||||
def _read_fan_speed_psutil(s) -> float:
|
||||
psutil = s._psutil
|
||||
if psutil is None:
|
||||
return 0.0
|
||||
fans = psutil.sensors_fans()
|
||||
if not fans:
|
||||
return 0.0
|
||||
if s._sensor_label:
|
||||
for group_name, entries in fans.items():
|
||||
for entry in entries:
|
||||
if entry.label == s._sensor_label or group_name == s._sensor_label:
|
||||
return entry.current
|
||||
for entries in fans.values():
|
||||
if entries:
|
||||
return entries[0].current
|
||||
return 0.0
|
||||
|
||||
|
||||
def _read_gpu(metric: str) -> ReaderFn:
|
||||
"""Build a GPU reader for the given metric name ('gpu_load' or 'gpu_temp')."""
|
||||
|
||||
def _read(s) -> float:
|
||||
if s._gpu_unavailable:
|
||||
return 0.0
|
||||
try:
|
||||
from ledgrab.utils.gpu import nvml, nvml_available, nvml_handle
|
||||
|
||||
if not nvml_available or nvml_handle is None:
|
||||
s._gpu_unavailable = True
|
||||
return 0.0
|
||||
if metric == "gpu_load":
|
||||
util = nvml.nvmlDeviceGetUtilizationRates(nvml_handle)
|
||||
return float(util.gpu)
|
||||
# gpu_temp
|
||||
return float(nvml.nvmlDeviceGetTemperature(nvml_handle, 0))
|
||||
except Exception as e:
|
||||
logger.debug("GPU metric read error: %s", e)
|
||||
s._gpu_unavailable = True
|
||||
return 0.0
|
||||
|
||||
return _read
|
||||
|
||||
|
||||
def _read_network_rate(s) -> float:
|
||||
"""Bytes/s rate for ``network_rx`` / ``network_tx``.
|
||||
|
||||
Mutates ``s._prev_net_bytes`` and ``s._prev_net_time`` to track the
|
||||
delta between calls — the stream owns the cadence state, this reader
|
||||
just bumps it forward.
|
||||
"""
|
||||
psutil = s._psutil
|
||||
if psutil is None:
|
||||
return 0.0
|
||||
counters = psutil.net_io_counters()
|
||||
if not counters:
|
||||
return 0.0
|
||||
current_bytes = counters.bytes_recv if s._metric == "network_rx" else counters.bytes_sent
|
||||
now = time.monotonic()
|
||||
if s._prev_net_bytes is None or s._prev_net_time is None:
|
||||
s._prev_net_bytes = current_bytes
|
||||
s._prev_net_time = now
|
||||
return 0.0
|
||||
dt = now - s._prev_net_time
|
||||
if dt <= 0:
|
||||
return 0.0
|
||||
# Cap delta time to avoid spikes after long gaps
|
||||
dt = min(dt, s._poll_interval * 2)
|
||||
rate = (current_bytes - s._prev_net_bytes) / dt
|
||||
s._prev_net_bytes = current_bytes
|
||||
s._prev_net_time = now
|
||||
return max(0.0, rate)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read helpers — fallback (no-psutil) paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _read_cpu_load_fallback(_s) -> float:
|
||||
from ledgrab.utils.metrics import get_metrics_provider
|
||||
|
||||
return get_metrics_provider().cpu_percent()
|
||||
|
||||
|
||||
def _read_ram_usage_fallback(_s) -> float:
|
||||
from ledgrab.utils.metrics import get_metrics_provider
|
||||
|
||||
mem = get_metrics_provider().virtual_memory()
|
||||
if mem.total_bytes > 0:
|
||||
return (mem.used_bytes / mem.total_bytes) * 100.0
|
||||
return 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prime helpers — one-time setup from start()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _prime_cpu_load(s) -> None:
|
||||
# Prime psutil.cpu_percent so the first real call returns meaningful data
|
||||
if s._psutil is not None:
|
||||
s._psutil.cpu_percent(interval=None)
|
||||
|
||||
|
||||
def _prime_network(s) -> None:
|
||||
"""Capture initial network counter so the first delta has a baseline."""
|
||||
if s._psutil is None:
|
||||
return
|
||||
counters = s._psutil.net_io_counters()
|
||||
if counters:
|
||||
s._prev_net_bytes = (
|
||||
counters.bytes_recv if s._metric == "network_rx" else counters.bytes_sent
|
||||
)
|
||||
s._prev_net_time = time.monotonic()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
METRIC_SPECS: Dict[str, MetricSpec] = {
|
||||
"cpu_load": MetricSpec(
|
||||
"cpu_load", _read_cpu_load_psutil, _read_cpu_load_fallback, _norm_percent, _prime_cpu_load
|
||||
),
|
||||
"ram_usage": MetricSpec(
|
||||
"ram_usage", _read_ram_usage_psutil, _read_ram_usage_fallback, _norm_percent
|
||||
),
|
||||
"disk_usage": MetricSpec("disk_usage", _read_disk_usage_psutil, _zero, _norm_percent),
|
||||
"battery_level": MetricSpec("battery_level", _read_battery_psutil, _zero, _norm_percent),
|
||||
"cpu_temp": MetricSpec("cpu_temp", _read_cpu_temp_psutil, _zero, _norm_range),
|
||||
"fan_speed": MetricSpec("fan_speed", _read_fan_speed_psutil, _zero, _norm_range),
|
||||
"gpu_load": MetricSpec("gpu_load", _read_gpu("gpu_load"), _zero, _norm_percent),
|
||||
"gpu_temp": MetricSpec("gpu_temp", _read_gpu("gpu_temp"), _zero, _norm_range),
|
||||
"network_rx": MetricSpec("network_rx", _read_network_rate, _zero, _norm_rate, _prime_network),
|
||||
"network_tx": MetricSpec("network_tx", _read_network_rate, _zero, _norm_rate, _prime_network),
|
||||
}
|
||||
|
||||
|
||||
def get_spec(metric: str) -> Optional[MetricSpec]:
|
||||
"""Look up the spec for ``metric``, returning ``None`` for unknown names."""
|
||||
return METRIC_SPECS.get(metric)
|
||||
@@ -22,11 +22,17 @@ class ProcessedColorStripStream(ColorStripStream):
|
||||
"""
|
||||
|
||||
def __init__(self, source, css_manager, cspt_store=None):
|
||||
import uuid as _uuid
|
||||
|
||||
self._source = source
|
||||
self._css_manager = css_manager
|
||||
self._cspt_store = cspt_store
|
||||
self._input_stream: Optional[ColorStripStream] = None
|
||||
self._consumer_id = f"__processed_{source.id}__"
|
||||
# Unique per instance so concurrent consumers don't collide on
|
||||
# sub-stream consumer IDs (e.g. two preview WS connections against
|
||||
# the same processed source).
|
||||
self._instance_id = _uuid.uuid4().hex[:8]
|
||||
self._consumer_id = f"__processed_{source.id}__{self._instance_id}__"
|
||||
self._filters = []
|
||||
self._cached_template_id = None
|
||||
self._running = False
|
||||
@@ -97,9 +103,28 @@ class ProcessedColorStripStream(ColorStripStream):
|
||||
return self._colors
|
||||
|
||||
def update_source(self, source) -> None:
|
||||
old_input = self._source.input_source_id
|
||||
new_input = source.input_source_id
|
||||
self._source = source
|
||||
# Force re-resolve filters on next iteration
|
||||
self._cached_template_id = None
|
||||
# If the input source changed while running, swap the acquired stream.
|
||||
if self._running and new_input != old_input:
|
||||
if self._input_stream and old_input:
|
||||
try:
|
||||
self._css_manager.release(old_input, self._consumer_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Processed update: release of old input {old_input} failed: {e}"
|
||||
)
|
||||
self._input_stream = None
|
||||
if new_input:
|
||||
try:
|
||||
self._input_stream = self._css_manager.acquire(new_input, self._consumer_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Processed update: acquire of new input {new_input} failed: {e}"
|
||||
)
|
||||
|
||||
def set_clock(self, clock_runtime) -> None:
|
||||
if self._input_stream and hasattr(self._input_stream, "set_clock"):
|
||||
@@ -135,9 +160,11 @@ class ProcessedColorStripStream(ColorStripStream):
|
||||
self._resolve_count = 0
|
||||
self._resolve_filters()
|
||||
|
||||
colors = None
|
||||
if self._input_stream:
|
||||
colors = self._input_stream.get_latest_colors()
|
||||
# Bind to a local first — ``update_source()`` may swap or null
|
||||
# out ``_input_stream`` between the check and the read on a
|
||||
# different thread.
|
||||
inp = self._input_stream
|
||||
colors = inp.get_latest_colors() if inp is not None else None
|
||||
|
||||
if colors is not None and self._filters:
|
||||
for flt in self._filters:
|
||||
|
||||
@@ -38,6 +38,7 @@ from ledgrab.storage.picture_source_store import PictureSourceStore
|
||||
from ledgrab.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||
from ledgrab.storage.template_store import TemplateStore
|
||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
|
||||
from ledgrab.storage.asset_store import AssetStore
|
||||
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
||||
from ledgrab.core.weather.weather_manager import WeatherManager
|
||||
@@ -71,8 +72,10 @@ class ProcessorDependencies:
|
||||
weather_manager: Optional[WeatherManager] = None
|
||||
asset_store: Optional[AssetStore] = None
|
||||
ha_manager: Optional[Any] = None # HomeAssistantManager
|
||||
mqtt_manager: Optional[Any] = None # MQTTManager
|
||||
game_event_bus: Optional[Any] = None # GameEventBus
|
||||
audio_processing_template_store: Optional[Any] = None # AudioProcessingTemplateStore
|
||||
http_endpoint_store: Optional[HTTPEndpointStore] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -168,6 +171,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
event_bus=deps.game_event_bus,
|
||||
audio_processing_template_store=deps.audio_processing_template_store,
|
||||
sync_clock_manager=deps.sync_clock_manager,
|
||||
http_endpoint_store=deps.http_endpoint_store,
|
||||
)
|
||||
if deps.value_source_store
|
||||
else None
|
||||
@@ -175,6 +179,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
# Wire value stream manager into CSS stream manager for composite layer brightness
|
||||
self._color_strip_stream_manager._value_stream_manager = self._value_stream_manager
|
||||
self._ha_manager = deps.ha_manager
|
||||
self._mqtt_manager = deps.mqtt_manager
|
||||
self._overlay_manager = OverlayManager()
|
||||
self._event_queues: List[asyncio.Queue] = []
|
||||
self._metrics_history = MetricsHistory(self)
|
||||
@@ -223,6 +228,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
self._devices.get(did), "test_mode_active", False
|
||||
),
|
||||
ha_manager=self._ha_manager,
|
||||
mqtt_manager=self._mqtt_manager,
|
||||
)
|
||||
|
||||
# ===== EVENT SYSTEM (state change notifications) =====
|
||||
@@ -465,6 +471,55 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
self._processors[target_id] = proc
|
||||
logger.info(f"Registered HA light target: {target_id}")
|
||||
|
||||
def add_z2m_light_target(
|
||||
self,
|
||||
target_id: str,
|
||||
mqtt_source_id: str,
|
||||
source_kind: str = "css",
|
||||
color_strip_source_id: str = "",
|
||||
color_value_source_id: str = "",
|
||||
brightness=None,
|
||||
light_mappings=None,
|
||||
base_topic: str = "zigbee2mqtt",
|
||||
update_rate: float = 5.0,
|
||||
transition=None,
|
||||
min_brightness_threshold: int = 0,
|
||||
color_tolerance: int = 5,
|
||||
stop_action: str = "none",
|
||||
) -> None:
|
||||
"""Register a Zigbee2MQTT light target processor.
|
||||
|
||||
``mqtt_source_id`` references an entry in the MQTTSource store. The
|
||||
processor will acquire/release that broker's runtime via the manager.
|
||||
"""
|
||||
if target_id in self._processors:
|
||||
raise ValueError(f"Z2M light target {target_id} already registered")
|
||||
if not mqtt_source_id:
|
||||
raise ValueError("mqtt_source_id is required for Z2M light targets")
|
||||
|
||||
from ledgrab.core.processing.z2m_light_target_processor import (
|
||||
Z2MLightTargetProcessor,
|
||||
)
|
||||
|
||||
proc = Z2MLightTargetProcessor(
|
||||
target_id=target_id,
|
||||
mqtt_source_id=mqtt_source_id,
|
||||
source_kind=source_kind,
|
||||
color_strip_source_id=color_strip_source_id,
|
||||
color_value_source_id=color_value_source_id,
|
||||
brightness=brightness,
|
||||
light_mappings=light_mappings or [],
|
||||
base_topic=base_topic,
|
||||
update_rate=update_rate,
|
||||
transition=transition,
|
||||
min_brightness_threshold=min_brightness_threshold,
|
||||
color_tolerance=color_tolerance,
|
||||
stop_action=stop_action,
|
||||
ctx=self._build_context(),
|
||||
)
|
||||
self._processors[target_id] = proc
|
||||
logger.info(f"Registered Z2M light target: {target_id} -> mqtt[{mqtt_source_id}]")
|
||||
|
||||
def remove_target(self, target_id: str):
|
||||
"""Unregister a target (any type)."""
|
||||
if target_id not in self._processors:
|
||||
@@ -755,6 +810,38 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
def is_css_overlay_active(self, css_id: str) -> bool:
|
||||
return self._overlay_manager.is_running(css_id)
|
||||
|
||||
# ===== HA LIGHT — MANUAL TURN OFF =====
|
||||
|
||||
async def turn_off_ha_light_target(self, target_id: str) -> int:
|
||||
"""Turn off all HA light entities mapped by the given target.
|
||||
|
||||
Works whether or not the target's processor is running. Returns the
|
||||
number of entities the turn_off was issued for.
|
||||
"""
|
||||
from ledgrab.core.processing.ha_light_target_processor import HALightTargetProcessor
|
||||
|
||||
proc = self._get_processor(target_id)
|
||||
if not isinstance(proc, HALightTargetProcessor):
|
||||
raise ValueError(f"Target {target_id} is not an HA light target")
|
||||
return await proc.turn_off_lights()
|
||||
|
||||
# ===== Z2M LIGHT — MANUAL TURN OFF =====
|
||||
|
||||
async def turn_off_z2m_light_target(self, target_id: str) -> int:
|
||||
"""Publish OFF to all Z2M bulbs mapped by the given target.
|
||||
|
||||
Works whether or not the target's processor is running. Returns the
|
||||
number of bulbs the turn-off was issued for.
|
||||
"""
|
||||
from ledgrab.core.processing.z2m_light_target_processor import (
|
||||
Z2MLightTargetProcessor,
|
||||
)
|
||||
|
||||
proc = self._get_processor(target_id)
|
||||
if not isinstance(proc, Z2MLightTargetProcessor):
|
||||
raise ValueError(f"Target {target_id} is not a Z2M light target")
|
||||
return await proc.turn_off_lights()
|
||||
|
||||
# ===== WEBSOCKET (delegates to processor) =====
|
||||
|
||||
def add_ha_light_ws_client(self, target_id: str, ws) -> None:
|
||||
@@ -766,6 +853,15 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
if proc:
|
||||
proc.remove_ws_client(ws)
|
||||
|
||||
def add_z2m_light_ws_client(self, target_id: str, ws) -> None:
|
||||
proc = self._get_processor(target_id)
|
||||
proc.add_ws_client(ws)
|
||||
|
||||
def remove_z2m_light_ws_client(self, target_id: str, ws) -> None:
|
||||
proc = self._processors.get(target_id)
|
||||
if proc:
|
||||
proc.remove_ws_client(ws)
|
||||
|
||||
def add_led_preview_client(self, target_id: str, ws) -> None:
|
||||
proc = self._get_processor(target_id)
|
||||
proc.add_led_preview_client(ws)
|
||||
|
||||
@@ -97,6 +97,7 @@ class TargetContext:
|
||||
fire_event: Callable[[dict], None] = lambda e: None
|
||||
is_test_mode_active: Callable[[str], bool] = lambda _: False
|
||||
ha_manager: Optional[Any] = None # HomeAssistantManager (avoid circular import)
|
||||
mqtt_manager: Optional[Any] = None # MQTTManager (avoid circular import)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""Single source of truth for value-stream construction.
|
||||
|
||||
``ValueStreamManager._create_stream`` used to be a 168-line ``isinstance``
|
||||
ladder over 14 ``ValueSource`` subclasses with a silent fallback to
|
||||
``StaticValueStream(value=1.0)``. The ladder forced any new value kind to
|
||||
edit the factory plus the storage subclass plus the schemas plus the store's
|
||||
``create_source`` — and a missing branch corrupted the stream at runtime.
|
||||
|
||||
This module replaces the ladder with a single ``STREAM_BUILDERS`` registry
|
||||
keyed by the ``source_type`` string (matching the storage layer's
|
||||
``_VALUE_SOURCE_MAP``). An import-time coverage assertion guarantees the two
|
||||
registries stay aligned.
|
||||
|
||||
Builders take a ``(source, deps: ValueStreamDeps) -> ValueStream`` shape so
|
||||
both the production manager and any preview / test harness can populate the
|
||||
deps from their own context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Callable, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Typed forward references so mypy/pyright catch typos like
|
||||
# ``d.gradient_stroe`` at static-analysis time. At runtime these are
|
||||
# ``Any`` — the live objects come from the FastAPI / manager wiring.
|
||||
from ledgrab.core.audio.audio_capture_manager import AudioCaptureManager
|
||||
from ledgrab.core.game_integration.event_bus import GameEventBus
|
||||
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
|
||||
from ledgrab.core.processing.color_strip_stream_manager import (
|
||||
ColorStripStreamManager,
|
||||
)
|
||||
from ledgrab.core.processing.live_stream_manager import LiveStreamManager
|
||||
from ledgrab.core.processing.sync_clock_manager import SyncClockRuntime
|
||||
from ledgrab.storage.audio_processing_template_store import (
|
||||
AudioProcessingTemplateStore,
|
||||
)
|
||||
from ledgrab.storage.audio_source_store import AudioSourceStore
|
||||
from ledgrab.storage.audio_template_store import AudioTemplateStore
|
||||
from ledgrab.storage.gradient_store import GradientStore
|
||||
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValueStreamDeps:
|
||||
"""Dependency bag for value-stream construction.
|
||||
|
||||
Builders only read the subset they need. ``value_stream_manager`` is the
|
||||
one truly load-bearing field — it is referenced by
|
||||
``GradientMapValueStream`` so it can recursively acquire its source
|
||||
value stream.
|
||||
|
||||
``clock_runtime`` is pre-acquired by the manager exclusively for the
|
||||
``animated_color`` kind (see :data:`NEEDS_CLOCK_RUNTIME`). The manager
|
||||
owns the bookkeeping for tracking ``vs_id → clock_id`` so the builder
|
||||
stays a pure ``(source, deps) -> stream`` mapping. If a new kind ever
|
||||
grows a clock dependency, add it to ``NEEDS_CLOCK_RUNTIME`` AND surface
|
||||
a separate field — sharing ``clock_runtime`` across kinds invites the
|
||||
wrong runtime being passed to the wrong builder.
|
||||
|
||||
Field types are quoted so they stay informational under
|
||||
``TYPE_CHECKING`` and the dataclass still accepts plain mocks at
|
||||
runtime. Builders therefore get IDE/lint help against typos like
|
||||
``d.gradient_stroe`` while production code remains duck-typed.
|
||||
"""
|
||||
|
||||
value_stream_manager: "Any"
|
||||
audio_capture_manager: Optional["AudioCaptureManager"] = None
|
||||
audio_source_store: Optional["AudioSourceStore"] = None
|
||||
audio_template_store: Optional["AudioTemplateStore"] = None
|
||||
audio_processing_template_store: Optional["AudioProcessingTemplateStore"] = None
|
||||
live_stream_manager: Optional["LiveStreamManager"] = None
|
||||
ha_manager: Optional["HomeAssistantManager"] = None
|
||||
gradient_store: Optional["GradientStore"] = None
|
||||
css_stream_manager: Optional["ColorStripStreamManager"] = None
|
||||
event_bus: Optional["GameEventBus"] = None
|
||||
http_endpoint_store: Optional["HTTPEndpointStore"] = None
|
||||
clock_runtime: Optional["SyncClockRuntime"] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-kind builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_static(source, _d: ValueStreamDeps):
|
||||
from ledgrab.core.processing.value_stream import StaticValueStream
|
||||
|
||||
return StaticValueStream(value=source.value)
|
||||
|
||||
|
||||
def _build_animated(source, _d: ValueStreamDeps):
|
||||
from ledgrab.core.processing.value_stream import AnimatedValueStream
|
||||
|
||||
return AnimatedValueStream(
|
||||
waveform=source.waveform,
|
||||
speed=source.speed,
|
||||
min_value=source.min_value,
|
||||
max_value=source.max_value,
|
||||
)
|
||||
|
||||
|
||||
def _build_audio(source, d: ValueStreamDeps):
|
||||
from ledgrab.core.processing.value_stream import AudioValueStream
|
||||
|
||||
return AudioValueStream(
|
||||
audio_source_id=source.audio_source_id,
|
||||
mode=source.mode,
|
||||
sensitivity=source.sensitivity,
|
||||
smoothing=source.smoothing,
|
||||
min_value=source.min_value,
|
||||
max_value=source.max_value,
|
||||
auto_gain=source.auto_gain,
|
||||
audio_capture_manager=d.audio_capture_manager,
|
||||
audio_source_store=d.audio_source_store,
|
||||
audio_template_store=d.audio_template_store,
|
||||
audio_processing_template_store=d.audio_processing_template_store,
|
||||
)
|
||||
|
||||
|
||||
def _build_daylight(source, _d: ValueStreamDeps):
|
||||
from ledgrab.core.processing.value_stream import DaylightValueStream
|
||||
|
||||
return DaylightValueStream(
|
||||
speed=source.speed,
|
||||
use_real_time=source.use_real_time,
|
||||
latitude=source.latitude,
|
||||
longitude=source.longitude,
|
||||
min_value=source.min_value,
|
||||
max_value=source.max_value,
|
||||
)
|
||||
|
||||
|
||||
def _build_adaptive_time(source, _d: ValueStreamDeps):
|
||||
from ledgrab.core.processing.value_stream import TimeOfDayValueStream
|
||||
|
||||
return TimeOfDayValueStream(
|
||||
schedule=source.schedule,
|
||||
min_value=source.min_value,
|
||||
max_value=source.max_value,
|
||||
)
|
||||
|
||||
|
||||
def _build_adaptive_scene(source, d: ValueStreamDeps):
|
||||
from ledgrab.core.processing.value_stream import SceneValueStream
|
||||
|
||||
return SceneValueStream(
|
||||
picture_source_id=source.picture_source_id,
|
||||
scene_behavior=source.scene_behavior,
|
||||
sensitivity=source.sensitivity,
|
||||
smoothing=source.smoothing,
|
||||
min_value=source.min_value,
|
||||
max_value=source.max_value,
|
||||
live_stream_manager=d.live_stream_manager,
|
||||
)
|
||||
|
||||
|
||||
def _build_static_color(source, _d: ValueStreamDeps):
|
||||
from ledgrab.core.processing.value_stream import StaticColorValueStream
|
||||
|
||||
return StaticColorValueStream(color=source.color)
|
||||
|
||||
|
||||
def _build_animated_color(source, d: ValueStreamDeps):
|
||||
# See NEEDS_CLOCK_RUNTIME below: ``d.clock_runtime`` is pre-acquired by
|
||||
# ``ValueStreamManager._create_stream`` exclusively for this kind. Any
|
||||
# other builder that ever needs a clock should add its own deps field
|
||||
# rather than read this one.
|
||||
from ledgrab.core.processing.value_stream import AnimatedColorValueStream
|
||||
|
||||
return AnimatedColorValueStream(
|
||||
colors=source.colors,
|
||||
speed=source.speed,
|
||||
easing=source.easing,
|
||||
clock=d.clock_runtime,
|
||||
)
|
||||
|
||||
|
||||
def _build_adaptive_time_color(source, _d: ValueStreamDeps):
|
||||
from ledgrab.core.processing.value_stream import AdaptiveTimeColorValueStream
|
||||
|
||||
return AdaptiveTimeColorValueStream(schedule=source.schedule)
|
||||
|
||||
|
||||
def _build_ha_entity(source, d: ValueStreamDeps):
|
||||
from ledgrab.core.processing.value_stream import HAEntityValueStream
|
||||
|
||||
return HAEntityValueStream(
|
||||
ha_source_id=source.ha_source_id,
|
||||
entity_id=source.entity_id,
|
||||
attribute=source.attribute,
|
||||
min_ha_value=source.min_ha_value,
|
||||
max_ha_value=source.max_ha_value,
|
||||
smoothing=source.smoothing,
|
||||
ha_manager=d.ha_manager,
|
||||
)
|
||||
|
||||
|
||||
def _build_gradient_map(source, d: ValueStreamDeps):
|
||||
from ledgrab.core.processing.value_stream import GradientMapValueStream
|
||||
|
||||
return GradientMapValueStream(
|
||||
value_source_id=source.value_source_id,
|
||||
gradient_id=source.gradient_id,
|
||||
easing=source.easing,
|
||||
value_stream_manager=d.value_stream_manager,
|
||||
gradient_store=d.gradient_store,
|
||||
)
|
||||
|
||||
|
||||
def _build_css_extract(source, d: ValueStreamDeps):
|
||||
from ledgrab.core.processing.value_stream import CSSExtractValueStream
|
||||
|
||||
return CSSExtractValueStream(
|
||||
color_strip_source_id=source.color_strip_source_id,
|
||||
led_start=source.led_start,
|
||||
led_end=source.led_end,
|
||||
css_stream_manager=d.css_stream_manager,
|
||||
)
|
||||
|
||||
|
||||
def _build_system_metrics(source, _d: ValueStreamDeps):
|
||||
from ledgrab.core.processing.value_stream import SystemMetricsValueStream
|
||||
|
||||
return SystemMetricsValueStream(
|
||||
metric=source.metric,
|
||||
min_value=source.min_value,
|
||||
max_value=source.max_value,
|
||||
max_rate=source.max_rate,
|
||||
disk_path=source.disk_path,
|
||||
sensor_label=source.sensor_label,
|
||||
poll_interval=source.poll_interval,
|
||||
smoothing=source.smoothing,
|
||||
)
|
||||
|
||||
|
||||
def _build_game_event(source, d: ValueStreamDeps):
|
||||
# Late import: ``GameEventValueStream`` lives in a separate sub-package
|
||||
# to keep the game-event subsystem (which transitively pulls in the
|
||||
# game-integration adapters) off the cold-start path for installs that
|
||||
# never use game events.
|
||||
from ledgrab.core.value_sources.game_event_value_source import GameEventValueStream
|
||||
|
||||
return GameEventValueStream(
|
||||
event_type=source.event_type,
|
||||
min_game_value=source.min_game_value,
|
||||
max_game_value=source.max_game_value,
|
||||
smoothing=source.smoothing,
|
||||
default_value=source.default_value,
|
||||
timeout=source.timeout,
|
||||
event_bus=d.event_bus,
|
||||
)
|
||||
|
||||
|
||||
def _build_http(source, d: ValueStreamDeps):
|
||||
from ledgrab.core.processing.value_stream import HTTPValueStream
|
||||
|
||||
return HTTPValueStream(
|
||||
endpoint_id=source.http_endpoint_id,
|
||||
json_path=source.json_path,
|
||||
interval_s=source.interval_s,
|
||||
min_value=source.min_value,
|
||||
max_value=source.max_value,
|
||||
smoothing=source.smoothing,
|
||||
http_endpoint_store=d.http_endpoint_store,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
StreamBuilder = Callable[[Any, ValueStreamDeps], Any]
|
||||
|
||||
STREAM_BUILDERS: dict[str, StreamBuilder] = {
|
||||
"static": _build_static,
|
||||
"animated": _build_animated,
|
||||
"audio": _build_audio,
|
||||
"daylight": _build_daylight,
|
||||
"adaptive_time": _build_adaptive_time,
|
||||
"adaptive_scene": _build_adaptive_scene,
|
||||
"static_color": _build_static_color,
|
||||
"animated_color": _build_animated_color,
|
||||
"adaptive_time_color": _build_adaptive_time_color,
|
||||
"ha_entity": _build_ha_entity,
|
||||
"gradient_map": _build_gradient_map,
|
||||
"css_extract": _build_css_extract,
|
||||
"system_metrics": _build_system_metrics,
|
||||
"game_event": _build_game_event,
|
||||
"http": _build_http,
|
||||
}
|
||||
|
||||
|
||||
# ``animated_color`` is the only kind that needs a pre-acquired SyncClockRuntime
|
||||
# from the manager; the rest derive everything they need from ``source`` and
|
||||
# ``deps``. Exposing this set lets the manager perform the side-effecting
|
||||
# acquisition step exactly once, before delegating to the registry.
|
||||
NEEDS_CLOCK_RUNTIME: frozenset[str] = frozenset({"animated_color"})
|
||||
|
||||
|
||||
def build_stream(source, deps: ValueStreamDeps):
|
||||
"""Build a ValueStream for *source*.
|
||||
|
||||
Raises ``ValueError`` when no builder is registered for
|
||||
``source.source_type``. Coverage is asserted at module import, so this
|
||||
only fires for an in-flight instance whose ``source_type`` somehow
|
||||
drifted from the registered set.
|
||||
"""
|
||||
builder = STREAM_BUILDERS.get(source.source_type)
|
||||
if builder is None:
|
||||
raise ValueError(
|
||||
f"No value-stream builder for source_type {source.source_type!r} "
|
||||
f"(id={getattr(source, 'id', '?')!r})"
|
||||
)
|
||||
return builder(source, deps)
|
||||
|
||||
|
||||
def _assert_value_kind_coverage() -> None:
|
||||
"""Verify the registry and storage's ``_VALUE_SOURCE_MAP`` agree.
|
||||
|
||||
Runs at module import. Symmetric: every kind in storage must have a
|
||||
builder, and every builder must correspond to a real storage kind.
|
||||
Also asserts ``NEEDS_CLOCK_RUNTIME`` only names kinds that exist in the
|
||||
registry, so a typo there fails the boot loudly instead of silently
|
||||
leaking a ``SyncClockRuntime`` acquisition.
|
||||
"""
|
||||
from ledgrab.storage.value_source import _VALUE_SOURCE_MAP
|
||||
|
||||
storage_kinds = set(_VALUE_SOURCE_MAP.keys())
|
||||
builder_kinds = set(STREAM_BUILDERS.keys())
|
||||
missing = storage_kinds - builder_kinds
|
||||
extra = builder_kinds - storage_kinds
|
||||
if missing or extra:
|
||||
problems = []
|
||||
if missing:
|
||||
problems.append(f"missing builders: {sorted(missing)}")
|
||||
if extra:
|
||||
problems.append(f"unregistered kinds: {sorted(extra)}")
|
||||
raise RuntimeError(
|
||||
"value_kinds.STREAM_BUILDERS is out of sync with storage._VALUE_SOURCE_MAP: "
|
||||
+ "; ".join(problems)
|
||||
)
|
||||
|
||||
rogue_clock_kinds = NEEDS_CLOCK_RUNTIME - builder_kinds
|
||||
if rogue_clock_kinds:
|
||||
raise RuntimeError(
|
||||
"value_kinds.NEEDS_CLOCK_RUNTIME names kinds with no registered "
|
||||
f"builder: {sorted(rogue_clock_kinds)}"
|
||||
)
|
||||
|
||||
|
||||
_assert_value_kind_coverage()
|
||||
@@ -21,7 +21,9 @@ ValueStreamManager owns all running ValueStreams, keyed by
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
@@ -29,8 +31,14 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.processing import metric_readers as _metric_readers
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
# Compiled once — used by ``_extract_simple_path`` on every poll.
|
||||
_NAME_HEAD_RE = re.compile(r"^([^\[]*)")
|
||||
_INDEX_RE = re.compile(r"^\[(\d+)\]")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ledgrab.core.audio.audio_capture import AudioCaptureManager
|
||||
from ledgrab.core.game_integration.event_bus import GameEventBus
|
||||
@@ -39,6 +47,7 @@ if TYPE_CHECKING:
|
||||
from ledgrab.core.processing.live_stream_manager import LiveStreamManager
|
||||
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
||||
from ledgrab.storage.audio_source_store import AudioSourceStore
|
||||
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
|
||||
from ledgrab.storage.value_source import ValueSource
|
||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||
|
||||
@@ -1017,6 +1026,211 @@ class HAEntityValueStream(ValueStream):
|
||||
logger.warning("HAEntityValueStream failed to swap HA runtime: %s", e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP poll
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HTTPValueStream(ValueStream):
|
||||
"""Periodically polls an HTTPEndpoint and extracts a value via json_path.
|
||||
|
||||
Exposes two accessors:
|
||||
|
||||
- ``get_value()`` returns a normalized float in [0, 1] for use as a
|
||||
modulator (brightness, color, etc.). The raw extracted value is
|
||||
coerced to float; non-numeric values yield 0.0.
|
||||
- ``get_raw_value()`` returns the un-normalized extracted value
|
||||
(str / int / float / bool / None) for consumers that need it
|
||||
verbatim — e.g. an automation rule comparing ``"playing"``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint_id: str,
|
||||
json_path: str,
|
||||
interval_s: int,
|
||||
min_value: float,
|
||||
max_value: float,
|
||||
smoothing: float,
|
||||
http_endpoint_store: Optional["HTTPEndpointStore"] = None,
|
||||
) -> None:
|
||||
self._endpoint_id = endpoint_id
|
||||
self._json_path = json_path
|
||||
self._interval_s = max(1, int(interval_s))
|
||||
self._min_value = min_value
|
||||
self._max_value = max_value
|
||||
self._smoothing = smoothing
|
||||
self._http_endpoint_store = http_endpoint_store
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._raw_value: Any = None
|
||||
self._prev_normalized: Optional[float] = None
|
||||
# Kept as private attrs for internal/log diagnostics; not exposed via
|
||||
# public properties or API until a status endpoint consumes them.
|
||||
self._last_fetched_at: Optional[datetime] = None
|
||||
self._last_status_code: Optional[int] = None
|
||||
self._last_error: Optional[str] = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._task is not None:
|
||||
return
|
||||
if not self._endpoint_id or self._http_endpoint_store is None:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No running loop — can't poll. Construction in a sync test
|
||||
# context is fine; the stream just stays idle until started
|
||||
# from an async context.
|
||||
return
|
||||
self._task = loop.create_task(self._poll_loop())
|
||||
|
||||
def stop(self) -> None:
|
||||
task = self._task
|
||||
self._task = None
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
|
||||
def get_value(self) -> float:
|
||||
raw = self._raw_value
|
||||
if raw is None:
|
||||
return self._prev_normalized if self._prev_normalized is not None else 0.0
|
||||
try:
|
||||
numeric = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return self._prev_normalized if self._prev_normalized is not None else 0.0
|
||||
|
||||
rng = self._max_value - self._min_value
|
||||
if abs(rng) < 1e-9:
|
||||
normalized = 0.5
|
||||
else:
|
||||
normalized = (numeric - self._min_value) / rng
|
||||
normalized = max(0.0, min(1.0, normalized))
|
||||
|
||||
if self._smoothing > 0.0 and self._prev_normalized is not None:
|
||||
normalized = (
|
||||
self._smoothing * self._prev_normalized + (1.0 - self._smoothing) * normalized
|
||||
)
|
||||
self._prev_normalized = normalized
|
||||
return normalized
|
||||
|
||||
def get_raw_value(self) -> Any:
|
||||
"""Return the last raw extracted value (string, int, float, etc.)."""
|
||||
return self._raw_value
|
||||
|
||||
def update_source(self, source: "ValueSource") -> None:
|
||||
from ledgrab.storage.value_source import HTTPValueSource
|
||||
|
||||
if not isinstance(source, HTTPValueSource):
|
||||
return
|
||||
self._endpoint_id = source.http_endpoint_id
|
||||
self._json_path = source.json_path
|
||||
self._interval_s = max(1, int(source.interval_s))
|
||||
self._min_value = source.min_value
|
||||
self._max_value = source.max_value
|
||||
self._smoothing = source.smoothing
|
||||
|
||||
async def _poll_loop(self) -> None:
|
||||
from ledgrab.utils.safe_source import safe_request_bounded
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
endpoint = self._http_endpoint_store.get(self._endpoint_id)
|
||||
except EntityNotFoundError:
|
||||
# The endpoint was deleted out from under us. Stop the
|
||||
# poll task so it doesn't spin forever; the next entity
|
||||
# event (or the value source being deleted) will tidy
|
||||
# the rest of the bookkeeping.
|
||||
logger.warning(
|
||||
"HTTPValueStream stopping: endpoint %s no longer exists",
|
||||
self._endpoint_id,
|
||||
)
|
||||
self._last_error = "endpoint_deleted"
|
||||
self._raw_value = None
|
||||
self._task = None
|
||||
return
|
||||
except Exception as exc:
|
||||
self._last_error = f"Endpoint lookup failed: {type(exc).__name__}"
|
||||
self._raw_value = None
|
||||
await asyncio.sleep(self._interval_s)
|
||||
continue
|
||||
|
||||
headers = endpoint.build_request_headers()
|
||||
try:
|
||||
status, body_bytes, error = await safe_request_bounded(
|
||||
endpoint.method,
|
||||
endpoint.url,
|
||||
headers=headers,
|
||||
timeout=endpoint.timeout_s,
|
||||
)
|
||||
except Exception as exc:
|
||||
# safe_request_bounded raises HTTPException on URL
|
||||
# validation failure; treat that as a recoverable poll
|
||||
# error and try again next cycle.
|
||||
self._last_status_code = None
|
||||
self._last_error = f"URL validation failed: {type(exc).__name__}"
|
||||
self._raw_value = None
|
||||
await asyncio.sleep(self._interval_s)
|
||||
continue
|
||||
|
||||
self._last_status_code = status if status else None
|
||||
self._last_error = error
|
||||
if not error and status:
|
||||
try:
|
||||
body_text = body_bytes.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
body_text = ""
|
||||
body_json: Any
|
||||
try:
|
||||
body_json = json.loads(body_text) if body_text else None
|
||||
except (ValueError, TypeError):
|
||||
body_json = None
|
||||
self._raw_value = _extract_simple_path(body_json, self._json_path, body_text)
|
||||
else:
|
||||
self._raw_value = None
|
||||
self._last_fetched_at = datetime.now()
|
||||
|
||||
await asyncio.sleep(self._interval_s)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
|
||||
def _extract_simple_path(body_json: Any, path: str, body_text: str) -> Any:
|
||||
"""Extract a value via a dot-path (with optional ``[N]`` indices).
|
||||
|
||||
Uses module-level compiled regexes so repeated polls don't recompile.
|
||||
Returns ``None`` when the path doesn't resolve — runtime callers just
|
||||
need "the value, or nothing." Empty path returns the raw body text so
|
||||
plain-text endpoints work too.
|
||||
"""
|
||||
if not path:
|
||||
return body_text or None
|
||||
if body_json is None:
|
||||
return None
|
||||
current: Any = body_json
|
||||
for raw_segment in path.split("."):
|
||||
segment = raw_segment.strip()
|
||||
if not segment:
|
||||
continue
|
||||
name_match = _NAME_HEAD_RE.match(segment)
|
||||
name_part = name_match.group(1) if name_match else ""
|
||||
remainder = segment[len(name_part) :]
|
||||
if name_part:
|
||||
if not isinstance(current, dict) or name_part not in current:
|
||||
return None
|
||||
current = current[name_part]
|
||||
while remainder:
|
||||
idx_match = _INDEX_RE.match(remainder)
|
||||
if not idx_match:
|
||||
return None
|
||||
idx = int(idx_match.group(1))
|
||||
if not isinstance(current, list) or idx < 0 or idx >= len(current):
|
||||
return None
|
||||
current = current[idx]
|
||||
remainder = remainder[idx_match.end() :]
|
||||
return current
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gradient Map
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1313,19 +1527,11 @@ class SystemMetricsValueStream(ValueStream):
|
||||
self._psutil = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._psutil is None:
|
||||
return
|
||||
# Prime cpu_percent so the first real call returns meaningful data
|
||||
if self._metric == "cpu_load":
|
||||
self._psutil.cpu_percent(interval=None)
|
||||
# Prime network counters
|
||||
if self._metric in ("network_rx", "network_tx"):
|
||||
counters = self._psutil.net_io_counters()
|
||||
if counters:
|
||||
self._prev_net_bytes = (
|
||||
counters.bytes_recv if self._metric == "network_rx" else counters.bytes_sent
|
||||
)
|
||||
self._prev_net_time = time.monotonic()
|
||||
# Per-metric priming (e.g. seed cpu_percent or capture an initial
|
||||
# network counter) lives on the MetricSpec, keyed by ``self._metric``.
|
||||
spec = _metric_readers.get_spec(self._metric)
|
||||
if spec is not None and spec.prime is not None:
|
||||
spec.prime(self)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._prev_value = None
|
||||
@@ -1357,154 +1563,29 @@ class SystemMetricsValueStream(ValueStream):
|
||||
return self._raw_value
|
||||
|
||||
def _normalize(self, raw: float) -> float:
|
||||
"""Normalize raw value to [0, 1]."""
|
||||
if self._metric in ("cpu_load", "ram_usage", "gpu_load", "battery_level", "disk_usage"):
|
||||
return max(0.0, min(1.0, raw / 100.0))
|
||||
elif self._metric in ("cpu_temp", "gpu_temp", "fan_speed"):
|
||||
rng = self._max_val - self._min_val
|
||||
if abs(rng) < 1e-9:
|
||||
return 0.5
|
||||
return max(0.0, min(1.0, (raw - self._min_val) / rng))
|
||||
elif self._metric in ("network_rx", "network_tx"):
|
||||
if self._max_rate <= 0:
|
||||
return 0.5
|
||||
return max(0.0, min(1.0, raw / self._max_rate))
|
||||
return 0.0
|
||||
"""Normalize raw value to [0, 1] via the metric's registered normaliser."""
|
||||
spec = _metric_readers.get_spec(self._metric)
|
||||
if spec is None:
|
||||
return 0.0
|
||||
return spec.normalize(self, raw)
|
||||
|
||||
def _read_metric(self) -> float:
|
||||
"""Read the raw metric value from the system.
|
||||
"""Read the raw metric value via the registered reader.
|
||||
|
||||
When psutil is unavailable (Android), falls back to the
|
||||
platform-aware MetricsProvider for cpu/memory and returns 0.0
|
||||
for desktop-only metrics.
|
||||
When psutil is unavailable (Android), the spec's ``read_fallback``
|
||||
path is used — desktop-only sensors return 0.0 there. Read errors
|
||||
are swallowed and the last cached raw value is returned.
|
||||
"""
|
||||
spec = _metric_readers.get_spec(self._metric)
|
||||
if spec is None:
|
||||
return 0.0
|
||||
reader = spec.read_psutil if self._psutil is not None else spec.read_fallback
|
||||
try:
|
||||
if self._psutil is not None:
|
||||
return self._read_metric_psutil()
|
||||
return self._read_metric_fallback()
|
||||
return reader(self)
|
||||
except Exception as e:
|
||||
logger.debug("SystemMetricsValueStream read error (%s): %s", self._metric, e)
|
||||
return self._raw_value if self._raw_value is not None else 0.0
|
||||
|
||||
def _read_metric_psutil(self) -> float:
|
||||
"""Read metrics via psutil (desktop path)."""
|
||||
psutil = self._psutil
|
||||
if self._metric == "cpu_load":
|
||||
return psutil.cpu_percent(interval=None)
|
||||
elif self._metric == "ram_usage":
|
||||
return psutil.virtual_memory().percent
|
||||
elif self._metric == "disk_usage":
|
||||
return psutil.disk_usage(self._disk_path).percent
|
||||
elif self._metric == "battery_level":
|
||||
bat = psutil.sensors_battery()
|
||||
return bat.percent if bat else 0.0
|
||||
elif self._metric == "cpu_temp":
|
||||
return self._read_cpu_temp()
|
||||
elif self._metric == "fan_speed":
|
||||
return self._read_fan_speed()
|
||||
elif self._metric in ("gpu_load", "gpu_temp"):
|
||||
return self._read_gpu_metric()
|
||||
elif self._metric in ("network_rx", "network_tx"):
|
||||
return self._read_network_rate()
|
||||
return 0.0
|
||||
|
||||
def _read_metric_fallback(self) -> float:
|
||||
"""Read metrics without psutil (Android / fallback path).
|
||||
|
||||
Uses the MetricsProvider abstraction for cpu/memory. Sensors,
|
||||
battery, network, disk, and GPU are not available.
|
||||
"""
|
||||
from ledgrab.utils.metrics import get_metrics_provider
|
||||
|
||||
provider = get_metrics_provider()
|
||||
if self._metric == "cpu_load":
|
||||
return provider.cpu_percent()
|
||||
elif self._metric == "ram_usage":
|
||||
mem = provider.virtual_memory()
|
||||
if mem.total_bytes > 0:
|
||||
return (mem.used_bytes / mem.total_bytes) * 100.0
|
||||
return 0.0
|
||||
return 0.0
|
||||
|
||||
def _read_cpu_temp(self) -> float:
|
||||
psutil = self._psutil
|
||||
if psutil is None:
|
||||
return 0.0
|
||||
temps = psutil.sensors_temperatures()
|
||||
if not temps:
|
||||
return 0.0
|
||||
# If sensor_label specified, try to find it
|
||||
if self._sensor_label:
|
||||
for group_name, entries in temps.items():
|
||||
for entry in entries:
|
||||
if entry.label == self._sensor_label or group_name == self._sensor_label:
|
||||
return entry.current
|
||||
# Fallback: first available sensor
|
||||
for entries in temps.values():
|
||||
if entries:
|
||||
return entries[0].current
|
||||
return 0.0
|
||||
|
||||
def _read_fan_speed(self) -> float:
|
||||
psutil = self._psutil
|
||||
if psutil is None:
|
||||
return 0.0
|
||||
fans = psutil.sensors_fans()
|
||||
if not fans:
|
||||
return 0.0
|
||||
if self._sensor_label:
|
||||
for group_name, entries in fans.items():
|
||||
for entry in entries:
|
||||
if entry.label == self._sensor_label or group_name == self._sensor_label:
|
||||
return entry.current
|
||||
# Fallback: first available fan
|
||||
for entries in fans.values():
|
||||
if entries:
|
||||
return entries[0].current
|
||||
return 0.0
|
||||
|
||||
def _read_gpu_metric(self) -> float:
|
||||
if self._gpu_unavailable:
|
||||
return 0.0
|
||||
try:
|
||||
from ledgrab.utils.gpu import nvml, nvml_available, nvml_handle
|
||||
|
||||
if not nvml_available or nvml_handle is None:
|
||||
self._gpu_unavailable = True
|
||||
return 0.0
|
||||
if self._metric == "gpu_load":
|
||||
util = nvml.nvmlDeviceGetUtilizationRates(nvml_handle)
|
||||
return float(util.gpu)
|
||||
else: # gpu_temp
|
||||
return float(nvml.nvmlDeviceGetTemperature(nvml_handle, 0))
|
||||
except Exception as e:
|
||||
logger.debug("GPU metric read error: %s", e)
|
||||
self._gpu_unavailable = True
|
||||
return 0.0
|
||||
|
||||
def _read_network_rate(self) -> float:
|
||||
psutil = self._psutil
|
||||
if psutil is None:
|
||||
return 0.0
|
||||
counters = psutil.net_io_counters()
|
||||
if not counters:
|
||||
return 0.0
|
||||
current_bytes = counters.bytes_recv if self._metric == "network_rx" else counters.bytes_sent
|
||||
now = time.monotonic()
|
||||
if self._prev_net_bytes is None or self._prev_net_time is None:
|
||||
self._prev_net_bytes = current_bytes
|
||||
self._prev_net_time = now
|
||||
return 0.0
|
||||
dt = now - self._prev_net_time
|
||||
if dt <= 0:
|
||||
return 0.0
|
||||
# Cap delta time to avoid spikes after long gaps
|
||||
dt = min(dt, self._poll_interval * 2)
|
||||
rate = (current_bytes - self._prev_net_bytes) / dt
|
||||
self._prev_net_bytes = current_bytes
|
||||
self._prev_net_time = now
|
||||
return max(0.0, rate)
|
||||
|
||||
def update_source(self, source: "ValueSource") -> None:
|
||||
from ledgrab.storage.value_source import SystemMetricsValueSource
|
||||
|
||||
@@ -1547,6 +1628,7 @@ class ValueStreamManager:
|
||||
event_bus: Optional["GameEventBus"] = None,
|
||||
audio_processing_template_store=None,
|
||||
sync_clock_manager: Optional["SyncClockManager"] = None,
|
||||
http_endpoint_store: Optional["HTTPEndpointStore"] = None,
|
||||
):
|
||||
self._value_source_store = value_source_store
|
||||
self._audio_capture_manager = audio_capture_manager
|
||||
@@ -1559,6 +1641,7 @@ class ValueStreamManager:
|
||||
self._event_bus = event_bus
|
||||
self._audio_processing_template_store = audio_processing_template_store
|
||||
self._sync_clock_manager = sync_clock_manager
|
||||
self._http_endpoint_store = http_endpoint_store
|
||||
self._streams: Dict[str, ValueStream] = {} # vs_id → stream
|
||||
self._ref_counts: Dict[str, int] = {} # vs_id → ref count
|
||||
# Tracks which clock_id (if any) was acquired for each stream so we
|
||||
@@ -1602,6 +1685,17 @@ class ValueStreamManager:
|
||||
else:
|
||||
logger.info(f"Released ref for value stream {vs_id} (refs={refs})")
|
||||
|
||||
def peek(self, vs_id: str) -> Optional[ValueStream]:
|
||||
"""Read-only accessor: return the running ValueStream for ``vs_id``
|
||||
if one exists, else ``None``.
|
||||
|
||||
Does NOT change ref counts. Use for consumer-driven reads where the
|
||||
caller already holds a reference via :meth:`acquire` (e.g. the
|
||||
:class:`AutomationEngine` evaluating an ``HTTPPollRule`` against a
|
||||
value source it has already acquired in ``_sync_value_stream_refs``).
|
||||
"""
|
||||
return self._streams.get(vs_id)
|
||||
|
||||
def update_source(self, vs_id: str) -> None:
|
||||
"""Hot-update the shared stream for the given ValueSource."""
|
||||
try:
|
||||
@@ -1699,158 +1793,52 @@ class ValueStreamManager:
|
||||
logger.info("Released all value streams")
|
||||
|
||||
def _create_stream(self, source: "ValueSource", vs_id: Optional[str] = None) -> ValueStream:
|
||||
"""Factory: create the appropriate ValueStream for a ValueSource."""
|
||||
from ledgrab.storage.value_source import (
|
||||
AdaptiveValueSource,
|
||||
AnimatedValueSource,
|
||||
AudioValueSource,
|
||||
CSSExtractValueSource,
|
||||
DaylightValueSource,
|
||||
GameEventValueSource,
|
||||
GradientMapValueSource,
|
||||
HAEntityValueSource,
|
||||
StaticValueSource,
|
||||
StaticColorValueSource,
|
||||
AnimatedColorValueSource,
|
||||
AdaptiveTimeColorValueSource,
|
||||
SystemMetricsValueSource,
|
||||
"""Build a ValueStream for *source* via the central kind registry.
|
||||
|
||||
The 14-branch ``isinstance`` ladder this method used to host was the
|
||||
canonical example of the parallel-change smell flagged in the
|
||||
architecture audit. The actual per-kind construction now lives in
|
||||
``ledgrab.core.processing.value_kinds.STREAM_BUILDERS``, keyed by
|
||||
``source.source_type``. This method only handles the manager-side
|
||||
bookkeeping that does not fit a uniform builder signature — namely
|
||||
the optional :class:`SyncClockRuntime` acquisition for
|
||||
``animated_color`` sources, whose ``vs_id → clock_id`` mapping the
|
||||
manager owns for symmetric release at teardown.
|
||||
"""
|
||||
from ledgrab.core.processing.value_kinds import (
|
||||
NEEDS_CLOCK_RUNTIME,
|
||||
ValueStreamDeps,
|
||||
build_stream,
|
||||
)
|
||||
|
||||
if isinstance(source, StaticValueSource):
|
||||
return StaticValueStream(value=source.value)
|
||||
|
||||
if isinstance(source, AnimatedValueSource):
|
||||
return AnimatedValueStream(
|
||||
waveform=source.waveform,
|
||||
speed=source.speed,
|
||||
min_value=source.min_value,
|
||||
max_value=source.max_value,
|
||||
)
|
||||
|
||||
if isinstance(source, AudioValueSource):
|
||||
return AudioValueStream(
|
||||
audio_source_id=source.audio_source_id,
|
||||
mode=source.mode,
|
||||
sensitivity=source.sensitivity,
|
||||
smoothing=source.smoothing,
|
||||
min_value=source.min_value,
|
||||
max_value=source.max_value,
|
||||
auto_gain=source.auto_gain,
|
||||
audio_capture_manager=self._audio_capture_manager,
|
||||
audio_source_store=self._audio_source_store,
|
||||
audio_template_store=self._audio_template_store,
|
||||
audio_processing_template_store=self._audio_processing_template_store,
|
||||
)
|
||||
|
||||
if isinstance(source, DaylightValueSource):
|
||||
return DaylightValueStream(
|
||||
speed=source.speed,
|
||||
use_real_time=source.use_real_time,
|
||||
latitude=source.latitude,
|
||||
longitude=source.longitude,
|
||||
min_value=source.min_value,
|
||||
max_value=source.max_value,
|
||||
)
|
||||
|
||||
if isinstance(source, AdaptiveValueSource):
|
||||
if source.source_type == "adaptive_scene":
|
||||
return SceneValueStream(
|
||||
picture_source_id=source.picture_source_id,
|
||||
scene_behavior=source.scene_behavior,
|
||||
sensitivity=source.sensitivity,
|
||||
smoothing=source.smoothing,
|
||||
min_value=source.min_value,
|
||||
max_value=source.max_value,
|
||||
live_stream_manager=self._live_stream_manager,
|
||||
)
|
||||
return TimeOfDayValueStream(
|
||||
schedule=source.schedule,
|
||||
min_value=source.min_value,
|
||||
max_value=source.max_value,
|
||||
)
|
||||
|
||||
# Color streams
|
||||
if isinstance(source, StaticColorValueSource):
|
||||
return StaticColorValueStream(color=source.color)
|
||||
|
||||
if isinstance(source, AnimatedColorValueSource):
|
||||
clock_runtime = None
|
||||
if source.clock_id and self._sync_clock_manager:
|
||||
clock_runtime = None
|
||||
if source.source_type in NEEDS_CLOCK_RUNTIME:
|
||||
clock_id = getattr(source, "clock_id", None)
|
||||
if clock_id and self._sync_clock_manager:
|
||||
try:
|
||||
clock_runtime = self._sync_clock_manager.acquire(source.clock_id)
|
||||
clock_runtime = self._sync_clock_manager.acquire(clock_id)
|
||||
if vs_id is not None:
|
||||
self._stream_clock_ids[vs_id] = source.clock_id
|
||||
self._stream_clock_ids[vs_id] = clock_id
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not acquire sync clock %s for value source %s: %s",
|
||||
source.clock_id,
|
||||
clock_id,
|
||||
source.id,
|
||||
e,
|
||||
)
|
||||
return AnimatedColorValueStream(
|
||||
colors=source.colors,
|
||||
speed=source.speed,
|
||||
easing=source.easing,
|
||||
clock=clock_runtime,
|
||||
)
|
||||
|
||||
if isinstance(source, AdaptiveTimeColorValueSource):
|
||||
return AdaptiveTimeColorValueStream(schedule=source.schedule)
|
||||
|
||||
if isinstance(source, HAEntityValueSource):
|
||||
return HAEntityValueStream(
|
||||
ha_source_id=source.ha_source_id,
|
||||
entity_id=source.entity_id,
|
||||
attribute=source.attribute,
|
||||
min_ha_value=source.min_ha_value,
|
||||
max_ha_value=source.max_ha_value,
|
||||
smoothing=source.smoothing,
|
||||
ha_manager=self._ha_manager,
|
||||
)
|
||||
|
||||
if isinstance(source, GradientMapValueSource):
|
||||
return GradientMapValueStream(
|
||||
value_source_id=source.value_source_id,
|
||||
gradient_id=source.gradient_id,
|
||||
easing=source.easing,
|
||||
value_stream_manager=self,
|
||||
gradient_store=self._gradient_store,
|
||||
)
|
||||
|
||||
if isinstance(source, CSSExtractValueSource):
|
||||
return CSSExtractValueStream(
|
||||
color_strip_source_id=source.color_strip_source_id,
|
||||
led_start=source.led_start,
|
||||
led_end=source.led_end,
|
||||
css_stream_manager=self._css_stream_manager,
|
||||
)
|
||||
|
||||
if isinstance(source, SystemMetricsValueSource):
|
||||
return SystemMetricsValueStream(
|
||||
metric=source.metric,
|
||||
min_value=source.min_value,
|
||||
max_value=source.max_value,
|
||||
max_rate=source.max_rate,
|
||||
disk_path=source.disk_path,
|
||||
sensor_label=source.sensor_label,
|
||||
poll_interval=source.poll_interval,
|
||||
smoothing=source.smoothing,
|
||||
)
|
||||
|
||||
if isinstance(source, GameEventValueSource):
|
||||
from ledgrab.core.value_sources.game_event_value_source import (
|
||||
GameEventValueStream,
|
||||
)
|
||||
|
||||
return GameEventValueStream(
|
||||
event_type=source.event_type,
|
||||
min_game_value=source.min_game_value,
|
||||
max_game_value=source.max_game_value,
|
||||
smoothing=source.smoothing,
|
||||
default_value=source.default_value,
|
||||
timeout=source.timeout,
|
||||
event_bus=self._event_bus,
|
||||
)
|
||||
|
||||
# Fallback
|
||||
return StaticValueStream(value=1.0)
|
||||
deps = ValueStreamDeps(
|
||||
value_stream_manager=self,
|
||||
audio_capture_manager=self._audio_capture_manager,
|
||||
audio_source_store=self._audio_source_store,
|
||||
audio_template_store=self._audio_template_store,
|
||||
audio_processing_template_store=self._audio_processing_template_store,
|
||||
live_stream_manager=self._live_stream_manager,
|
||||
ha_manager=self._ha_manager,
|
||||
gradient_store=self._gradient_store,
|
||||
css_stream_manager=self._css_stream_manager,
|
||||
event_bus=self._event_bus,
|
||||
http_endpoint_store=self._http_endpoint_store,
|
||||
clock_runtime=clock_runtime,
|
||||
)
|
||||
return build_stream(source, deps)
|
||||
|
||||
@@ -37,6 +37,33 @@ def is_youtube_url(url: str) -> bool:
|
||||
return any(p.search(url) for p in _YT_PATTERNS)
|
||||
|
||||
|
||||
# Network schemes accepted by ``cv2.VideoCapture``. ``file://`` is intentionally
|
||||
# excluded — local files are supported via plain path strings (no scheme), so
|
||||
# explicit ``file://`` requests can only be an attempt to coerce FFmpeg into
|
||||
# loading something the path-string code path would reject. ``concat:``,
|
||||
# ``gopher://``, ``crypto:``, etc. are not allowed.
|
||||
_ALLOWED_NETWORK_SCHEMES: tuple[str, ...] = ("http", "https", "rtsp", "rtsps")
|
||||
|
||||
|
||||
def _assert_video_url_allowed(url: str) -> None:
|
||||
"""Reject video URLs that use anything other than a vetted scheme.
|
||||
|
||||
OpenCV/FFmpeg supports many esoteric input protocols (``concat:``,
|
||||
``gopher://``, ``crypto:``, ``udp://``, ``async:``, …). Some can read
|
||||
arbitrary host files or pivot to internal addresses when the caller
|
||||
can influence the URL. Tighten the input to the schemes we actually
|
||||
advertise. URLs without a scheme are accepted as local-file paths.
|
||||
"""
|
||||
if "://" not in url:
|
||||
return # plain local path — OpenCV resolves against the working dir
|
||||
scheme = url.split("://", 1)[0].lower()
|
||||
if scheme not in _ALLOWED_NETWORK_SCHEMES:
|
||||
raise RuntimeError(
|
||||
f"Refusing to open video with unsupported scheme {scheme!r}; "
|
||||
f"allowed: {', '.join(_ALLOWED_NETWORK_SCHEMES)} or a local file path."
|
||||
)
|
||||
|
||||
|
||||
def resolve_youtube_url(url: str, resolution_limit: Optional[int] = None) -> str:
|
||||
"""Resolve a YouTube URL to a direct stream URL using yt-dlp."""
|
||||
try:
|
||||
@@ -144,6 +171,7 @@ class VideoCaptureLiveStream(LiveStream):
|
||||
target_fps: int = 30,
|
||||
):
|
||||
_require_cv2()
|
||||
super().__init__()
|
||||
self._original_url = url
|
||||
self._resolved_url: Optional[str] = None
|
||||
self._loop = loop
|
||||
@@ -184,10 +212,14 @@ class VideoCaptureLiveStream(LiveStream):
|
||||
if self._running:
|
||||
return
|
||||
|
||||
# Resolve YouTube URL if needed
|
||||
# Resolve YouTube URL if needed. Validate AFTER resolution too, so a
|
||||
# malicious yt-dlp result (or a redirect we don't expect) can't slip
|
||||
# through with an unsupported scheme.
|
||||
actual_url = self._original_url
|
||||
_assert_video_url_allowed(actual_url)
|
||||
if is_youtube_url(actual_url):
|
||||
actual_url = resolve_youtube_url(actual_url, self._resolution_limit)
|
||||
_assert_video_url_allowed(actual_url)
|
||||
self._resolved_url = actual_url
|
||||
|
||||
# Open capture
|
||||
@@ -348,6 +380,7 @@ class VideoCaptureLiveStream(LiveStream):
|
||||
sc = ScreenCapture(image=buf, width=w, height=h, display_index=-1)
|
||||
with self._frame_lock:
|
||||
self._latest_frame = sc
|
||||
self._signal_new_frame()
|
||||
|
||||
except Exception as e:
|
||||
consecutive_errors += 1
|
||||
|
||||
@@ -136,7 +136,10 @@ class WledTargetProcessor(TargetProcessor):
|
||||
self._device_config = config
|
||||
|
||||
# Connect to LED device
|
||||
deps = ProviderDeps(device_store=self._ctx.device_store)
|
||||
deps = ProviderDeps(
|
||||
device_store=self._ctx.device_store,
|
||||
mqtt_manager=getattr(self._ctx, "mqtt_manager", None),
|
||||
)
|
||||
try:
|
||||
self._led_client = create_led_client(config, deps=deps)
|
||||
await self._led_client.connect()
|
||||
@@ -221,7 +224,8 @@ class WledTargetProcessor(TargetProcessor):
|
||||
|
||||
self._is_running = False
|
||||
|
||||
# Cancel task
|
||||
# Cancel task. The cancellation is awaited above, so the prior
|
||||
# 50 ms ``asyncio.sleep`` here was pure dead time on every stop().
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
@@ -230,7 +234,6 @@ class WledTargetProcessor(TargetProcessor):
|
||||
logger.debug("WLED target processor task cancelled")
|
||||
pass
|
||||
self._task = None
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# Restore device state (only if auto_shutdown is enabled)
|
||||
if self._led_client and self._device_state_before:
|
||||
@@ -845,6 +848,11 @@ class WledTargetProcessor(TargetProcessor):
|
||||
prev_frame_ref = None
|
||||
has_any_frame = False
|
||||
_prev_brightness = -1 # force first send
|
||||
# Cache max-pixel per frame ref. ``np.max`` over a 200-300 LED array
|
||||
# at 60 fps × N targets is non-trivial; we only need to recompute it
|
||||
# when the frame reference itself changes.
|
||||
_max_pixel_cached: int = 0
|
||||
_max_pixel_for_frame: object = None
|
||||
|
||||
# Pre-allocate brightness scratch (uint16 intermediate + uint8 output)
|
||||
_bright_u16: Optional[np.ndarray] = None
|
||||
@@ -1012,7 +1020,12 @@ class WledTargetProcessor(TargetProcessor):
|
||||
# If below cutoff → snap to 0 (LEDs off).
|
||||
_thresh = self._min_brightness_threshold
|
||||
if _thresh > 0 and cur_brightness > 0:
|
||||
max_pixel = int(np.max(frame))
|
||||
if frame is _max_pixel_for_frame:
|
||||
max_pixel = _max_pixel_cached
|
||||
else:
|
||||
max_pixel = int(np.max(frame))
|
||||
_max_pixel_cached = max_pixel
|
||||
_max_pixel_for_frame = frame
|
||||
if max_pixel * cur_brightness // 255 < _thresh:
|
||||
cur_brightness = 0
|
||||
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
"""Zigbee2MQTT light target processor — publishes RGB+brightness directly to Z2M topics.
|
||||
|
||||
Reads from a ColorStripStream or a colour-returning ValueStream, averages LED segments
|
||||
to single RGB values, and publishes ``{"color":{"r","g","b"}, "brightness", "transition"}``
|
||||
payloads to ``<base_topic>/<friendly_name>/set`` on the shared MQTT broker.
|
||||
|
||||
Bypasses Home Assistant — bulbs must already be paired with the Z2M coordinator.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.processing.light_target_helpers import swap_color_source
|
||||
from ledgrab.core.processing.target_processor import TargetContext, TargetProcessor
|
||||
from ledgrab.storage.z2m_light_output_target import (
|
||||
DEFAULT_Z2M_BASE_TOPIC,
|
||||
Z2MLightMapping,
|
||||
)
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class Z2MLightTargetProcessor(TargetProcessor):
|
||||
"""Streams averaged LED colors to Zigbee2MQTT bulbs via MQTT."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target_id: str,
|
||||
mqtt_source_id: str,
|
||||
source_kind: str = "css",
|
||||
color_strip_source_id: str = "",
|
||||
color_value_source_id: str = "",
|
||||
brightness=None,
|
||||
light_mappings: Optional[List[Z2MLightMapping]] = None,
|
||||
base_topic: str = DEFAULT_Z2M_BASE_TOPIC,
|
||||
update_rate: float = 5.0,
|
||||
transition=None,
|
||||
min_brightness_threshold: int = 0,
|
||||
color_tolerance: int = 5,
|
||||
stop_action: str = "none",
|
||||
ctx: Optional[TargetContext] = None,
|
||||
):
|
||||
from ledgrab.storage.bindable import BindableFloat, bfloat
|
||||
|
||||
super().__init__(target_id, ctx)
|
||||
self._mqtt_source_id = mqtt_source_id
|
||||
self._source_kind = source_kind if source_kind in ("css", "color_vs") else "css"
|
||||
self._css_id = color_strip_source_id
|
||||
self._color_vs_id = color_value_source_id
|
||||
if brightness is not None and isinstance(brightness, BindableFloat):
|
||||
self._brightness = brightness
|
||||
else:
|
||||
self._brightness = BindableFloat(1.0)
|
||||
if transition is not None and isinstance(transition, BindableFloat):
|
||||
self._transition = transition
|
||||
else:
|
||||
self._transition = BindableFloat(float(transition) if transition is not None else 0.3)
|
||||
self._light_mappings = light_mappings or []
|
||||
self._base_topic = (base_topic or "").strip() or DEFAULT_Z2M_BASE_TOPIC
|
||||
# Z2M ceiling — 10 Hz is the realistic Zigbee-mesh upper bound per bulb.
|
||||
self._update_rate = max(0.5, min(10.0, bfloat(update_rate, 5.0)))
|
||||
self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
|
||||
self._color_tolerance = int(bfloat(color_tolerance, 5.0))
|
||||
self._stop_action = stop_action if stop_action in ("none", "turn_off") else "none"
|
||||
|
||||
# Runtime state
|
||||
self._css_stream = None
|
||||
self._color_stream = None
|
||||
self._value_stream = None # brightness VS stream
|
||||
self._previous_colors: Dict[str, Tuple[int, int, int]] = {}
|
||||
self._previous_on: Dict[str, bool] = {}
|
||||
self._latest_entity_colors: Dict[str, Tuple[int, int, int]] = {}
|
||||
self._ws_clients: List[Any] = []
|
||||
self._start_time: Optional[float] = None
|
||||
# MQTT runtime acquired from MQTTManager at start(); released at stop().
|
||||
self._mqtt_runtime = None
|
||||
# Track whether we hold an outstanding acquire() so stop() knows to release.
|
||||
self._mqtt_acquired_id: Optional[str] = None
|
||||
|
||||
@property
|
||||
def device_id(self) -> Optional[str]:
|
||||
return None # Z2M targets don't use device providers
|
||||
|
||||
# ─────────── Lifecycle ───────────
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._is_running:
|
||||
return
|
||||
|
||||
if self._source_kind == "color_vs":
|
||||
if self._color_vs_id and self._ctx.value_stream_manager:
|
||||
try:
|
||||
self._color_stream = self._ctx.value_stream_manager.acquire(self._color_vs_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Z2M light {self._target_id}: failed to acquire color VS stream: {e}"
|
||||
)
|
||||
else:
|
||||
if self._css_id and self._ctx.color_strip_stream_manager:
|
||||
try:
|
||||
self._css_stream = self._ctx.color_strip_stream_manager.acquire(
|
||||
self._css_id, self._target_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Z2M light {self._target_id}: failed to acquire CSS stream: {e}"
|
||||
)
|
||||
|
||||
if self._brightness.source_id and self._ctx.value_stream_manager:
|
||||
try:
|
||||
self._value_stream = self._ctx.value_stream_manager.acquire(
|
||||
self._brightness.source_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Z2M light {self._target_id}: failed to acquire brightness VS: {e}")
|
||||
self._value_stream = None
|
||||
|
||||
# Acquire the MQTT runtime for our broker source. If the manager is
|
||||
# missing or the source can't be resolved, the runtime stays None and
|
||||
# publishes are dropped — the loop guards on it.
|
||||
mqtt_manager = getattr(self._ctx, "mqtt_manager", None)
|
||||
if mqtt_manager is None:
|
||||
logger.warning(
|
||||
f"Z2M light {self._target_id}: no MQTT manager in context — frames will be dropped"
|
||||
)
|
||||
elif not self._mqtt_source_id:
|
||||
logger.warning(
|
||||
f"Z2M light {self._target_id}: no mqtt_source_id configured — frames will be dropped"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
self._mqtt_runtime = await mqtt_manager.acquire(self._mqtt_source_id)
|
||||
self._mqtt_acquired_id = self._mqtt_source_id
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Z2M light {self._target_id}: failed to acquire MQTT runtime "
|
||||
f"for source {self._mqtt_source_id}: {e}"
|
||||
)
|
||||
|
||||
self._is_running = True
|
||||
self._start_time = time.monotonic()
|
||||
self._task = asyncio.create_task(self._processing_loop())
|
||||
logger.info(f"Z2M light target started: {self._target_id} -> mqtt[{self._mqtt_source_id}]")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._is_running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
try:
|
||||
await self._apply_stop_action()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Z2M light {self._target_id}: stop_action '{self._stop_action}' failed: {e}"
|
||||
)
|
||||
|
||||
if self._css_stream and self._ctx.color_strip_stream_manager:
|
||||
try:
|
||||
self._ctx.color_strip_stream_manager.release(self._css_id, self._target_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._css_stream = None
|
||||
|
||||
if self._color_stream is not None and self._ctx.value_stream_manager:
|
||||
try:
|
||||
self._ctx.value_stream_manager.release(self._color_vs_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._color_stream = None
|
||||
|
||||
if self._value_stream is not None and self._ctx.value_stream_manager:
|
||||
try:
|
||||
self._ctx.value_stream_manager.release(self._brightness.source_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._value_stream = None
|
||||
|
||||
# Release the MQTT runtime so the broker connection can be torn down
|
||||
# if no other consumer holds it.
|
||||
if self._mqtt_acquired_id is not None:
|
||||
mqtt_manager = getattr(self._ctx, "mqtt_manager", None)
|
||||
if mqtt_manager is not None:
|
||||
try:
|
||||
await mqtt_manager.release(self._mqtt_acquired_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Z2M light {self._target_id}: failed to release MQTT runtime: {e}"
|
||||
)
|
||||
self._mqtt_acquired_id = None
|
||||
self._mqtt_runtime = None
|
||||
|
||||
self._previous_colors.clear()
|
||||
self._previous_on.clear()
|
||||
self._latest_entity_colors.clear()
|
||||
self._ws_clients.clear()
|
||||
logger.info(f"Z2M light target stopped: {self._target_id}")
|
||||
|
||||
# ─────────── Settings ───────────
|
||||
|
||||
def update_settings(self, settings) -> None:
|
||||
from ledgrab.storage.bindable import BindableFloat, bfloat
|
||||
|
||||
if not isinstance(settings, dict):
|
||||
return
|
||||
if "update_rate" in settings:
|
||||
self._update_rate = max(0.5, min(10.0, bfloat(settings["update_rate"], 5.0)))
|
||||
if "transition" in settings:
|
||||
t = settings["transition"]
|
||||
self._transition = (
|
||||
t if isinstance(t, BindableFloat) else self._transition.apply_update(t)
|
||||
)
|
||||
if "brightness" in settings:
|
||||
b = settings["brightness"]
|
||||
self._brightness = (
|
||||
b if isinstance(b, BindableFloat) else self._brightness.apply_update(b)
|
||||
)
|
||||
if "base_topic" in settings:
|
||||
bt = (settings["base_topic"] or "").strip()
|
||||
self._base_topic = bt or DEFAULT_Z2M_BASE_TOPIC
|
||||
if "mqtt_source_id" in settings:
|
||||
# The broker swap itself is deferred to a stop/restart cycle
|
||||
# (acquire/release happens at start()/stop() boundaries). We
|
||||
# record the new id so the next start() uses the right runtime.
|
||||
new_id = settings["mqtt_source_id"] or ""
|
||||
if new_id != self._mqtt_source_id:
|
||||
self._mqtt_source_id = new_id
|
||||
if "min_brightness_threshold" in settings:
|
||||
self._min_brightness_threshold = int(bfloat(settings["min_brightness_threshold"], 0.0))
|
||||
if "color_tolerance" in settings:
|
||||
self._color_tolerance = int(bfloat(settings["color_tolerance"], 5.0))
|
||||
if "light_mappings" in settings:
|
||||
self._light_mappings = settings["light_mappings"]
|
||||
if "stop_action" in settings:
|
||||
sa = settings["stop_action"]
|
||||
if sa in ("none", "turn_off"):
|
||||
self._stop_action = sa
|
||||
|
||||
new_kind = settings.get("source_kind")
|
||||
new_color_vs = settings.get("color_value_source_id")
|
||||
kind_changed = new_kind in ("css", "color_vs") and new_kind != self._source_kind
|
||||
color_vs_changed = new_color_vs is not None and new_color_vs != self._color_vs_id
|
||||
if kind_changed or color_vs_changed:
|
||||
self._swap_color_source(
|
||||
new_kind if kind_changed else self._source_kind,
|
||||
new_color_vs if new_color_vs is not None else self._color_vs_id,
|
||||
)
|
||||
|
||||
def update_css_source(self, color_strip_source_id: str) -> None:
|
||||
old_id = self._css_id
|
||||
self._css_id = color_strip_source_id
|
||||
if self._source_kind == "css" and self._is_running and self._ctx.color_strip_stream_manager:
|
||||
try:
|
||||
new_stream = self._ctx.color_strip_stream_manager.acquire(
|
||||
color_strip_source_id, self._target_id
|
||||
)
|
||||
old_stream = self._css_stream
|
||||
self._css_stream = new_stream
|
||||
if old_stream:
|
||||
self._ctx.color_strip_stream_manager.release(old_id, self._target_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Z2M light {self._target_id}: CSS swap failed: {e}")
|
||||
|
||||
def _swap_color_source(self, new_kind: str, new_color_vs_id: str) -> None:
|
||||
"""Release the previous colour stream and acquire the new one."""
|
||||
swap_color_source(self, new_kind, new_color_vs_id, log_label="Z2M light")
|
||||
# Reset per-entity history so the new source isn't gated by stale values.
|
||||
self._previous_colors.clear()
|
||||
self._previous_on.clear()
|
||||
|
||||
# ─────────── WebSocket clients ───────────
|
||||
|
||||
def add_ws_client(self, ws: Any) -> None:
|
||||
self._ws_clients.append(ws)
|
||||
|
||||
def remove_ws_client(self, ws: Any) -> None:
|
||||
if ws in self._ws_clients:
|
||||
self._ws_clients.remove(ws)
|
||||
|
||||
def supports_websocket(self) -> bool:
|
||||
return True
|
||||
|
||||
# ─────────── State / metrics ───────────
|
||||
|
||||
def get_state(self) -> dict:
|
||||
uptime = time.monotonic() - self._start_time if self._start_time and self._is_running else 0
|
||||
entity_colors = {
|
||||
name: {"r": r, "g": g, "b": b, "hex": f"#{r:02x}{g:02x}{b:02x}"}
|
||||
for name, (r, g, b) in self._latest_entity_colors.items()
|
||||
}
|
||||
mqtt_connected = bool(self._mqtt_runtime and self._mqtt_runtime.is_connected)
|
||||
return {
|
||||
"target_id": self._target_id,
|
||||
"processing": self._is_running,
|
||||
"source_kind": self._source_kind,
|
||||
"css_id": self._css_id,
|
||||
"color_value_source_id": self._color_vs_id,
|
||||
"mqtt_source_id": self._mqtt_source_id,
|
||||
"is_running": self._is_running,
|
||||
"mqtt_connected": mqtt_connected,
|
||||
"base_topic": self._base_topic,
|
||||
"light_count": len(self._light_mappings),
|
||||
"update_rate": self._update_rate,
|
||||
"fps_actual": self._update_rate if self._is_running else None,
|
||||
"fps_target": self._update_rate,
|
||||
"fps_capture": self._update_rate if self._is_running else None,
|
||||
"uptime_seconds": uptime,
|
||||
"entity_colors": entity_colors,
|
||||
}
|
||||
|
||||
def get_metrics(self) -> dict:
|
||||
uptime = time.monotonic() - self._start_time if self._start_time and self._is_running else 0
|
||||
return {
|
||||
"target_id": self._target_id,
|
||||
"processing": self._is_running,
|
||||
"fps_actual": self._update_rate if self._is_running else None,
|
||||
"fps_target": self._update_rate,
|
||||
"uptime_seconds": uptime,
|
||||
"frames_processed": 0,
|
||||
"errors_count": 0,
|
||||
"last_error": None,
|
||||
"last_update": None,
|
||||
}
|
||||
|
||||
# ─────────── Processing loop ───────────
|
||||
|
||||
async def _processing_loop(self) -> None:
|
||||
interval = 1.0 / self._update_rate
|
||||
while self._is_running:
|
||||
try:
|
||||
loop_start = time.monotonic()
|
||||
|
||||
if self._mqtt_runtime is not None and self._mqtt_runtime.is_connected:
|
||||
if self._source_kind == "color_vs" and self._color_stream is not None:
|
||||
try:
|
||||
color = self._color_stream.get_color()
|
||||
except Exception:
|
||||
color = None
|
||||
if isinstance(color, (list, tuple)) and len(color) >= 3:
|
||||
await self._update_lights_single_color(
|
||||
int(color[0]), int(color[1]), int(color[2])
|
||||
)
|
||||
elif self._css_stream is not None:
|
||||
colors = self._css_stream.get_latest_colors()
|
||||
if colors is not None and len(colors) > 0:
|
||||
await self._update_lights(colors)
|
||||
|
||||
elapsed = time.monotonic() - loop_start
|
||||
# Refresh interval each loop so update_rate changes take effect immediately.
|
||||
interval = 1.0 / max(0.5, self._update_rate)
|
||||
sleep_time = max(0.05, interval - elapsed)
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Z2M light {self._target_id} loop error: {e}")
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
def _read_brightness_multiplier(self) -> float:
|
||||
if self._value_stream is None:
|
||||
return 1.0
|
||||
try:
|
||||
return float(self._value_stream.get_value())
|
||||
except Exception:
|
||||
return 1.0
|
||||
|
||||
def _topic_for(self, friendly_name: str) -> str:
|
||||
# Z2M topic convention: <base_topic>/<friendly_name>/set
|
||||
return f"{self._base_topic}/{friendly_name}/set"
|
||||
|
||||
async def _publish_payload(self, friendly_name: str, payload: dict) -> None:
|
||||
if not friendly_name or self._mqtt_runtime is None:
|
||||
return
|
||||
try:
|
||||
await self._mqtt_runtime.publish(
|
||||
self._topic_for(friendly_name),
|
||||
json.dumps(payload),
|
||||
retain=False,
|
||||
qos=0,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Z2M light {self._target_id}: publish failed for {friendly_name}: {e}")
|
||||
|
||||
async def _send_entity_color(
|
||||
self,
|
||||
mapping: Z2MLightMapping,
|
||||
r: int,
|
||||
g: int,
|
||||
b: int,
|
||||
vs_multiplier: float,
|
||||
) -> None:
|
||||
"""Apply tolerance/threshold gates and publish one Z2M command."""
|
||||
friendly = mapping.friendly_name
|
||||
if not friendly:
|
||||
return
|
||||
|
||||
# Cache for WS preview (always, even if MQTT is skipped).
|
||||
self._latest_entity_colors[friendly] = (r, g, b)
|
||||
|
||||
bs = (
|
||||
mapping.brightness_scale.value
|
||||
if hasattr(mapping.brightness_scale, "value")
|
||||
else mapping.brightness_scale
|
||||
)
|
||||
eff_scale = bs * vs_multiplier
|
||||
# Z2M uses 0..254 for `brightness` on Zigbee bulbs.
|
||||
brightness = max(0, min(254, int(max(r, g, b) * eff_scale)))
|
||||
|
||||
should_be_on = (
|
||||
brightness >= self._min_brightness_threshold or self._min_brightness_threshold == 0
|
||||
)
|
||||
|
||||
prev_color = self._previous_colors.get(friendly)
|
||||
was_on = self._previous_on.get(friendly, True)
|
||||
|
||||
if should_be_on:
|
||||
if prev_color is not None and was_on:
|
||||
dr = abs(r - prev_color[0])
|
||||
dg = abs(g - prev_color[1])
|
||||
db = abs(b - prev_color[2])
|
||||
if max(dr, dg, db) < self._color_tolerance:
|
||||
return # colour hasn't changed enough
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"state": "ON",
|
||||
"color": {"r": r, "g": g, "b": b},
|
||||
"brightness": brightness,
|
||||
}
|
||||
transition_val = self._transition.value
|
||||
if transition_val > 0:
|
||||
payload["transition"] = transition_val
|
||||
|
||||
await self._publish_payload(friendly, payload)
|
||||
self._previous_colors[friendly] = (r, g, b)
|
||||
self._previous_on[friendly] = True
|
||||
|
||||
elif was_on:
|
||||
await self._publish_payload(friendly, {"state": "OFF"})
|
||||
self._previous_on[friendly] = False
|
||||
self._previous_colors.pop(friendly, None)
|
||||
|
||||
async def _update_lights(self, colors: np.ndarray) -> None:
|
||||
led_count = len(colors)
|
||||
vs_multiplier = self._read_brightness_multiplier()
|
||||
|
||||
for mapping in self._light_mappings:
|
||||
if not mapping.friendly_name:
|
||||
continue
|
||||
start = max(0, mapping.led_start)
|
||||
end = mapping.led_end if mapping.led_end >= 0 else led_count
|
||||
end = min(end, led_count)
|
||||
if start >= end:
|
||||
continue
|
||||
segment = colors[start:end]
|
||||
avg = segment.mean(axis=0).astype(int)
|
||||
await self._send_entity_color(
|
||||
mapping, int(avg[0]), int(avg[1]), int(avg[2]), vs_multiplier
|
||||
)
|
||||
|
||||
if self._ws_clients and self._latest_entity_colors:
|
||||
await self._broadcast_entity_colors()
|
||||
|
||||
async def _update_lights_single_color(self, r: int, g: int, b: int) -> None:
|
||||
vs_multiplier = self._read_brightness_multiplier()
|
||||
for mapping in self._light_mappings:
|
||||
if not mapping.friendly_name:
|
||||
continue
|
||||
await self._send_entity_color(mapping, r, g, b, vs_multiplier)
|
||||
|
||||
if self._ws_clients and self._latest_entity_colors:
|
||||
await self._broadcast_entity_colors()
|
||||
|
||||
async def _broadcast_entity_colors(self) -> None:
|
||||
colors_payload = {
|
||||
name: {"r": r, "g": g, "b": b, "hex": f"#{r:02x}{g:02x}{b:02x}"}
|
||||
for name, (r, g, b) in self._latest_entity_colors.items()
|
||||
}
|
||||
message = json.dumps({"type": "colors_update", "colors": colors_payload})
|
||||
dead: List[Any] = []
|
||||
for ws in self._ws_clients:
|
||||
try:
|
||||
await ws.send_text(message)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
self._ws_clients.remove(ws)
|
||||
|
||||
# ─────────── Stop-action finalization ───────────
|
||||
|
||||
def _unique_mapped_friendly_names(self) -> List[str]:
|
||||
names: List[str] = []
|
||||
seen = set()
|
||||
for m in self._light_mappings:
|
||||
fn = m.friendly_name
|
||||
if fn and fn not in seen:
|
||||
seen.add(fn)
|
||||
names.append(fn)
|
||||
return names
|
||||
|
||||
async def turn_off_lights(self) -> int:
|
||||
"""Publish ``{"state":"OFF"}`` to every mapped bulb.
|
||||
|
||||
Works whether or not the processor is running. When the processor
|
||||
isn't running, temporarily borrows the runtime from the MQTT manager
|
||||
and releases it afterwards. Returns the number of bulbs the
|
||||
turn-off was issued for.
|
||||
"""
|
||||
names = self._unique_mapped_friendly_names()
|
||||
if not names:
|
||||
return 0
|
||||
|
||||
mqtt_manager = getattr(self._ctx, "mqtt_manager", None)
|
||||
if mqtt_manager is None:
|
||||
raise RuntimeError("MQTT manager not available")
|
||||
if not self._mqtt_source_id:
|
||||
raise RuntimeError("Target has no MQTT source configured")
|
||||
|
||||
# Use the already-acquired runtime when running; otherwise borrow one.
|
||||
runtime = self._mqtt_runtime
|
||||
borrowed = False
|
||||
if runtime is None:
|
||||
runtime = await mqtt_manager.acquire(self._mqtt_source_id)
|
||||
borrowed = True
|
||||
|
||||
try:
|
||||
if not runtime.is_connected:
|
||||
raise RuntimeError("MQTT broker not connected")
|
||||
# Swap in the borrowed runtime so _publish_payload uses it.
|
||||
prev_runtime = self._mqtt_runtime
|
||||
self._mqtt_runtime = runtime
|
||||
try:
|
||||
for name in names:
|
||||
await self._publish_payload(name, {"state": "OFF"})
|
||||
finally:
|
||||
self._mqtt_runtime = prev_runtime
|
||||
finally:
|
||||
if borrowed:
|
||||
try:
|
||||
await mqtt_manager.release(self._mqtt_source_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Z2M light {self._target_id}: failed to release borrowed runtime: {e}"
|
||||
)
|
||||
return len(names)
|
||||
|
||||
async def _apply_stop_action(self) -> None:
|
||||
if self._stop_action == "none":
|
||||
return
|
||||
if self._mqtt_runtime is None or not self._mqtt_runtime.is_connected:
|
||||
logger.info(
|
||||
f"Z2M light {self._target_id}: skipping stop_action "
|
||||
f"'{self._stop_action}' — MQTT not connected"
|
||||
)
|
||||
return
|
||||
if self._stop_action == "turn_off":
|
||||
for name in self._unique_mapped_friendly_names():
|
||||
await self._publish_payload(name, {"state": "OFF"})
|
||||
@@ -5,11 +5,13 @@ import hashlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -23,6 +25,7 @@ from ledgrab.core.update.release_provider import AssetInfo, ReleaseInfo, Release
|
||||
from ledgrab.core.update.version_check import is_newer, normalize_version
|
||||
from ledgrab.storage.database import Database
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.net_classify import is_blocked_for_ssrf
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -38,6 +41,44 @@ _SHA256_RE = re.compile(r"\b([a-fA-F0-9]{64})\b")
|
||||
_STARTUP_DELAY_S = 30
|
||||
_MANUAL_CHECK_DEBOUNCE_S = 60
|
||||
|
||||
# Manual-redirect limits for SSRF-safe update downloads.
|
||||
_UPDATE_MAX_REDIRECT_HOPS = 5
|
||||
|
||||
|
||||
def _validate_update_url(url: str) -> None:
|
||||
"""Reject update URLs whose scheme or resolved host is non-public.
|
||||
|
||||
The update pipeline fetches release feeds and binaries from
|
||||
``update.repo_url`` (default: Gitea instance) and may follow
|
||||
redirects to CDN hosts. Without per-hop validation, a hostile or
|
||||
compromised feed could redirect the binary download to a private
|
||||
address (SSRF) or to a non-HTTPS scheme. This guard enforces:
|
||||
|
||||
* scheme is ``http`` or ``https``
|
||||
* hostname is present
|
||||
* DNS resolution returns no private / loopback / link-local /
|
||||
multicast / reserved / unparseable address
|
||||
|
||||
Raises ``RuntimeError`` (not ``HTTPException`` — this code path runs
|
||||
in a background task, not a request handler).
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise RuntimeError(f"Refusing update URL with unsupported scheme: {parsed.scheme!r}")
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise RuntimeError("Update URL missing hostname")
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror as exc:
|
||||
raise RuntimeError(f"Cannot resolve update host: {hostname} ({exc})") from exc
|
||||
ips = {info[4][0] for info in infos}
|
||||
for ip in ips:
|
||||
if is_blocked_for_ssrf(ip):
|
||||
raise RuntimeError(
|
||||
f"Refusing update URL: host {hostname!r} resolves to " f"non-public address {ip}"
|
||||
)
|
||||
|
||||
|
||||
class UpdateService:
|
||||
"""Periodically polls a ReleaseProvider and fires WebSocket events."""
|
||||
@@ -250,29 +291,64 @@ class UpdateService:
|
||||
finally:
|
||||
self._downloading = False
|
||||
|
||||
async def _safe_get_text(self, url: str, timeout: float = 30.0) -> str:
|
||||
"""Fetch *url* as text with manual, SSRF-validated redirect handling."""
|
||||
current = url
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
|
||||
for _ in range(_UPDATE_MAX_REDIRECT_HOPS + 1):
|
||||
_validate_update_url(current)
|
||||
resp = await client.get(current)
|
||||
if resp.is_redirect:
|
||||
location = resp.headers.get("location")
|
||||
if not location:
|
||||
raise RuntimeError("Update redirect without Location header")
|
||||
current = str(httpx.URL(current).join(location))
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
raise RuntimeError(
|
||||
f"Too many redirects fetching update text (max {_UPDATE_MAX_REDIRECT_HOPS})"
|
||||
)
|
||||
|
||||
async def _stream_download(self, url: str, dest: Path, total_size: int) -> None:
|
||||
"""Stream-download a file, updating progress as we go."""
|
||||
"""Stream-download a file with manual, SSRF-validated redirect handling.
|
||||
|
||||
Each hop is re-validated via :func:`_validate_update_url` so a
|
||||
compromised release feed cannot redirect the binary download to a
|
||||
non-public address.
|
||||
"""
|
||||
tmp = dest.with_suffix(dest.suffix + ".tmp")
|
||||
received = 0
|
||||
async with httpx.AsyncClient(timeout=300, follow_redirects=True) as client:
|
||||
async with client.stream("GET", url) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(tmp, "wb") as f:
|
||||
async for chunk in resp.aiter_bytes(chunk_size=65536):
|
||||
f.write(chunk)
|
||||
received += len(chunk)
|
||||
if total_size > 0:
|
||||
self._download_progress = received / total_size
|
||||
if self._fire_event:
|
||||
self._fire_event(
|
||||
{
|
||||
"type": "update_download_progress",
|
||||
"progress": round(self._download_progress, 3),
|
||||
}
|
||||
)
|
||||
# Atomic rename
|
||||
tmp.replace(dest)
|
||||
self._download_progress = 1.0
|
||||
current = url
|
||||
async with httpx.AsyncClient(timeout=300, follow_redirects=False) as client:
|
||||
for _ in range(_UPDATE_MAX_REDIRECT_HOPS + 1):
|
||||
_validate_update_url(current)
|
||||
async with client.stream("GET", current) as resp:
|
||||
if resp.is_redirect:
|
||||
location = resp.headers.get("location")
|
||||
if not location:
|
||||
raise RuntimeError("Update redirect without Location header")
|
||||
current = str(httpx.URL(current).join(location))
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
with open(tmp, "wb") as f:
|
||||
async for chunk in resp.aiter_bytes(chunk_size=65536):
|
||||
f.write(chunk)
|
||||
received += len(chunk)
|
||||
if total_size > 0:
|
||||
self._download_progress = received / total_size
|
||||
if self._fire_event:
|
||||
self._fire_event(
|
||||
{
|
||||
"type": "update_download_progress",
|
||||
"progress": round(self._download_progress, 3),
|
||||
}
|
||||
)
|
||||
# Atomic rename
|
||||
tmp.replace(dest)
|
||||
self._download_progress = 1.0
|
||||
return
|
||||
raise RuntimeError(f"Too many redirects fetching update (max {_UPDATE_MAX_REDIRECT_HOPS})")
|
||||
|
||||
# ── Apply ──────────────────────────────────────────────────
|
||||
|
||||
@@ -324,20 +400,18 @@ class UpdateService:
|
||||
if not asset:
|
||||
return None
|
||||
|
||||
# 1) sibling .sha256 asset
|
||||
# 1) sibling .sha256 asset — fetch with manual, SSRF-validated
|
||||
# redirects so the checksum can't be sourced from an untrusted host.
|
||||
sibling = next(
|
||||
(a for a in release.assets if a.name == f"{asset.name}.sha256"),
|
||||
None,
|
||||
)
|
||||
if sibling:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||
resp = await client.get(sibling.download_url)
|
||||
resp.raise_for_status()
|
||||
text = resp.text.strip()
|
||||
match = _SHA256_RE.search(text)
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
text = await self._safe_get_text(sibling.download_url)
|
||||
match = _SHA256_RE.search(text.strip())
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch sibling sha256 asset: %s", exc)
|
||||
|
||||
|
||||
+124
-59
@@ -1,8 +1,10 @@
|
||||
"""FastAPI application entry point."""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Awaitable
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -48,13 +50,12 @@ from ledgrab.storage.game_integration_store import GameIntegrationStore
|
||||
from ledgrab.core.game_integration.event_bus import GameEventBus
|
||||
import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters
|
||||
from ledgrab.core.game_integration.community_loader import register_community_adapters
|
||||
from ledgrab.core.mqtt.mqtt_service import MQTTService
|
||||
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
|
||||
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
|
||||
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
|
||||
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
|
||||
from ledgrab.storage.pattern_template_store import PatternTemplateStore
|
||||
import ledgrab.core.audio.filters # noqa: F401 — trigger audio filter auto-registration
|
||||
from ledgrab.core.devices.mqtt_client import set_mqtt_service
|
||||
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
||||
from ledgrab.core.processing.os_notification_listener import OsNotificationListener
|
||||
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher
|
||||
@@ -71,6 +72,10 @@ logger = get_logger(__name__)
|
||||
# Get configuration
|
||||
config = get_config()
|
||||
|
||||
# The shutdown-complete signal is owned by a leaf module so ``__main__``
|
||||
# can import it without dragging in this module's heavy global state.
|
||||
from ledgrab.shutdown_state import shutdown_complete # noqa: E402
|
||||
|
||||
|
||||
def _migrate_legacy_data_location() -> None:
|
||||
"""Rescue data from pre-rename cwd-relative paths.
|
||||
@@ -167,6 +172,8 @@ weather_manager = WeatherManager(weather_source_store)
|
||||
ha_store = HomeAssistantStore(db)
|
||||
ha_manager = HomeAssistantManager(ha_store)
|
||||
mqtt_source_store = MQTTSourceStore(db)
|
||||
mqtt_manager = MQTTManager(mqtt_source_store)
|
||||
http_endpoint_store = HTTPEndpointStore(db)
|
||||
audio_processing_template_store = AudioProcessingTemplateStore(db)
|
||||
game_integration_store = GameIntegrationStore(db)
|
||||
pattern_template_store = PatternTemplateStore(db)
|
||||
@@ -189,8 +196,10 @@ processor_manager = ProcessorManager(
|
||||
weather_manager=weather_manager,
|
||||
asset_store=asset_store,
|
||||
ha_manager=ha_manager,
|
||||
mqtt_manager=mqtt_manager,
|
||||
game_event_bus=game_event_bus,
|
||||
audio_processing_template_store=audio_processing_template_store,
|
||||
http_endpoint_store=http_endpoint_store,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -238,23 +247,28 @@ async def lifespan(app: FastAPI):
|
||||
client_labels = ", ".join(config.auth.api_keys.keys())
|
||||
logger.info(f"Authorized clients: {client_labels}")
|
||||
|
||||
# Create MQTT service (shared broker connection — legacy, used by MQTTLEDClient)
|
||||
mqtt_service = MQTTService(config.mqtt)
|
||||
set_mqtt_service(mqtt_service)
|
||||
# One-shot migration: legacy global ``mqtt:`` config block → first MQTTSource.
|
||||
# No-op once the store has any entries.
|
||||
try:
|
||||
from ledgrab.core.mqtt.legacy_migration import migrate_legacy_mqtt_config
|
||||
|
||||
# Create MQTT manager (multi-source, ref-counted — new entity-based model)
|
||||
mqtt_manager = MQTTManager(mqtt_source_store)
|
||||
migrate_legacy_mqtt_config(mqtt_source_store)
|
||||
except Exception as e:
|
||||
logger.error("Legacy MQTT migration failed: %s", e)
|
||||
|
||||
# Create automation engine (needs processor_manager + mqtt + stores for scene activation)
|
||||
# Create automation engine. HTTPPollRule evaluation reads from a
|
||||
# ValueStream produced by the ValueStreamManager (which lives inside
|
||||
# the processor manager), so the engine needs that handle.
|
||||
automation_engine = AutomationEngine(
|
||||
automation_store,
|
||||
processor_manager,
|
||||
mqtt_service=mqtt_service,
|
||||
scene_preset_store=scene_preset_store,
|
||||
target_store=output_target_store,
|
||||
device_store=device_store,
|
||||
ha_manager=ha_manager,
|
||||
mqtt_manager=mqtt_manager,
|
||||
value_stream_manager=processor_manager.value_stream_manager,
|
||||
value_source_store=value_source_store,
|
||||
)
|
||||
|
||||
# Create auto-backup engine — derive paths from database location so that
|
||||
@@ -308,6 +322,7 @@ async def lifespan(app: FastAPI):
|
||||
game_event_bus=game_event_bus,
|
||||
mqtt_store=mqtt_source_store,
|
||||
mqtt_manager=mqtt_manager,
|
||||
http_endpoint_store=http_endpoint_store,
|
||||
audio_processing_template_store=audio_processing_template_store,
|
||||
pattern_template_store=pattern_template_store,
|
||||
)
|
||||
@@ -347,9 +362,6 @@ async def lifespan(app: FastAPI):
|
||||
# Start background health monitoring for all devices
|
||||
await processor_manager.start_health_monitoring()
|
||||
|
||||
# Start MQTT service (broker connection for output, triggers, state)
|
||||
await mqtt_service.start()
|
||||
|
||||
# Start automation engine (evaluates conditions and activates scenes)
|
||||
await automation_engine.start()
|
||||
|
||||
@@ -387,39 +399,50 @@ async def lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
#
|
||||
# Each step has a strict time budget. Windows gives a GUI app with a
|
||||
# shutdown-block-reason set ~20 s before it force-terminates the
|
||||
# process; if any single step stalls (network call to a dead WLED, a
|
||||
# zombie MQTT broker), we MUST keep moving so the steps that actually
|
||||
# protect the user's state — device restore frames and the DB
|
||||
# checkpoint — still get to run.
|
||||
logger.info("Shutting down LED Grab")
|
||||
|
||||
# Persist all stores to disk before stopping anything.
|
||||
# This ensures in-memory data survives force-kills and restarts
|
||||
# where no CRUD happened during the session.
|
||||
_save_all_stores()
|
||||
async def _bounded(label: str, coro: Awaitable, timeout: float) -> None:
|
||||
try:
|
||||
await asyncio.wait_for(coro, timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("Shutdown step '%s' exceeded %.1fs — moving on", label, timeout)
|
||||
except Exception as e:
|
||||
logger.error("Shutdown step '%s' raised: %s", label, e)
|
||||
|
||||
# Legacy hook — SQLite stores are write-through so this only logs.
|
||||
# Durability comes from PRAGMA synchronous=FULL + the explicit
|
||||
# wal_checkpoint(TRUNCATE) in Database.close() at the end of this block.
|
||||
try:
|
||||
_save_all_stores()
|
||||
except Exception as e:
|
||||
logger.error(f"Error persisting stores: {e}")
|
||||
|
||||
# Stop automation engine first so it can no longer activate scenes that
|
||||
# would talk to processors mid-shutdown.
|
||||
try:
|
||||
await automation_engine.stop()
|
||||
logger.info("Stopped automation engine")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping automation engine: {e}")
|
||||
await _bounded("automation_engine.stop", automation_engine.stop(), timeout=1.5)
|
||||
|
||||
# Stop discovery watcher and OS notification listener so they stop
|
||||
# firing events into a shutting-down processor manager.
|
||||
if discovery_watcher is not None:
|
||||
try:
|
||||
await discovery_watcher.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping discovery watcher: {e}")
|
||||
await _bounded("discovery_watcher.stop", discovery_watcher.stop(), timeout=1.0)
|
||||
|
||||
try:
|
||||
os_notif_listener.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping OS notification listener: {e}")
|
||||
|
||||
# Stop all processing BEFORE tearing down ha_manager / mqtt_manager /
|
||||
# mqtt_service. HA-light targets need a live HA runtime to apply their
|
||||
# stop_action (turn_off / restore), and MQTT-output devices need a live
|
||||
# MQTT broker connection to send restore frames. Shutting those down
|
||||
# first silently turns "stop_targets" into a no-op for those targets.
|
||||
# Stop all processing BEFORE tearing down ha_manager / mqtt_manager.
|
||||
# HA-light targets need a live HA runtime to apply their stop_action
|
||||
# (turn_off / restore), and MQTT-output targets need a live broker
|
||||
# runtime to send restore frames. Shutting those down first silently
|
||||
# turns "stop_targets" into a no-op for those targets.
|
||||
#
|
||||
# The shutdown_action setting controls whether per-device restore
|
||||
# frames are sent: "stop_targets" (default) runs the normal stop
|
||||
@@ -434,27 +457,18 @@ async def lifespan(app: FastAPI):
|
||||
action = "stop_targets"
|
||||
|
||||
logger.info("Shutdown action: %s", action)
|
||||
try:
|
||||
await processor_manager.stop_all(restore_devices=action != "nothing")
|
||||
logger.info("Stopped all processors")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping processors: {e}")
|
||||
# This is the step that *implements* the user's stop_targets setting.
|
||||
# Give it the largest slice of the budget.
|
||||
await _bounded(
|
||||
"processor_manager.stop_all",
|
||||
processor_manager.stop_all(restore_devices=action != "nothing"),
|
||||
timeout=8.0,
|
||||
)
|
||||
logger.info("Stopped all processors")
|
||||
|
||||
# Now safe to tear down the connections that processors depended on.
|
||||
try:
|
||||
await ha_manager.shutdown()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping Home Assistant manager: {e}")
|
||||
|
||||
try:
|
||||
await mqtt_manager.shutdown()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping MQTT manager: {e}")
|
||||
|
||||
try:
|
||||
await mqtt_service.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping MQTT service: {e}")
|
||||
await _bounded("ha_manager.shutdown", ha_manager.shutdown(), timeout=1.5)
|
||||
await _bounded("mqtt_manager.shutdown", mqtt_manager.shutdown(), timeout=1.5)
|
||||
|
||||
# Independent services — order doesn't matter relative to processors.
|
||||
try:
|
||||
@@ -462,26 +476,37 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping weather manager: {e}")
|
||||
|
||||
await _bounded("update_service.stop", update_service.stop(), timeout=0.5)
|
||||
await _bounded("auto_backup_engine.stop", auto_backup_engine.stop(), timeout=0.5)
|
||||
|
||||
# Close the DB last so it runs a TRUNCATE checkpoint, flushing the WAL
|
||||
# into the main file. Without this, writes can survive a graceful app
|
||||
# restart (Python finalizer checkpoints on GC) but be lost on a later
|
||||
# unclean PC shutdown — the symptom users see as "my fix reverted after
|
||||
# rebooting the PC."
|
||||
try:
|
||||
await update_service.stop()
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping update checker: {e}")
|
||||
logger.error(f"Error closing database: {e}")
|
||||
|
||||
try:
|
||||
await auto_backup_engine.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping auto-backup engine: {e}")
|
||||
# Tell any external supervisor (Windows shutdown guard, tray) that
|
||||
# cleanup is done so Windows can finish ending the session promptly.
|
||||
shutdown_complete.set()
|
||||
logger.info("Shutdown complete")
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
# Create FastAPI application. The built-in ``/docs``, ``/redoc``, and
|
||||
# ``/openapi.json`` routes are disabled here so they can be re-added below
|
||||
# with an :data:`AuthRequired` dependency — exposing the full OpenAPI surface
|
||||
# (route paths + parameter schemas) without auth is information disclosure.
|
||||
app = FastAPI(
|
||||
title="LED Grab",
|
||||
description="Control WLED devices based on screen content for ambient lighting",
|
||||
version=__version__,
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
@@ -528,6 +553,46 @@ async def _no_cache_static(request: Request, call_next):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# Middleware: baseline security headers on every response. CSP is intentionally
|
||||
# omitted here because the UI uses inline event handlers / templates and a
|
||||
# wrong CSP value would break the app; the other three headers are universally
|
||||
# safe defaults and close several common browser-side attack vectors.
|
||||
@app.middleware("http")
|
||||
async def _security_headers(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.headers.setdefault(
|
||||
"Permissions-Policy",
|
||||
"geolocation=(), microphone=(), camera=(), payment=()",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
# ── Auth-gated OpenAPI surface ────────────────────────────────────────────
|
||||
# Re-add the docs endpoints we disabled above, now protected by the same
|
||||
# Bearer auth as the rest of the API. When auth is unconfigured, loopback
|
||||
# clients still get in anonymously (per ``verify_api_key`` policy).
|
||||
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html # noqa: E402
|
||||
from ledgrab.api.auth import AuthRequired # noqa: E402
|
||||
|
||||
|
||||
@app.get("/openapi.json", include_in_schema=False)
|
||||
async def _openapi(_auth: AuthRequired):
|
||||
return JSONResponse(app.openapi())
|
||||
|
||||
|
||||
@app.get("/docs", include_in_schema=False)
|
||||
async def _swagger_docs(_auth: AuthRequired):
|
||||
return get_swagger_ui_html(openapi_url="/openapi.json", title=f"{app.title} — API docs")
|
||||
|
||||
|
||||
@app.get("/redoc", include_in_schema=False)
|
||||
async def _redoc_docs(_auth: AuthRequired):
|
||||
return get_redoc_html(openapi_url="/openapi.json", title=f"{app.title} — API docs")
|
||||
|
||||
|
||||
# Mount static files
|
||||
static_path = Path(__file__).parent / "static"
|
||||
if static_path.exists():
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Cross-thread shutdown completion signal.
|
||||
|
||||
This module is intentionally tiny so importing it does not pull in the
|
||||
heavy global state (Database, stores, processor manager) instantiated at
|
||||
import time by ``ledgrab.main``. ``__main__`` imports it on the main
|
||||
thread before uvicorn loads ``ledgrab.main`` in its event-loop thread;
|
||||
both ends share the same ``threading.Event`` instance.
|
||||
|
||||
The lifespan in ``ledgrab.main`` calls ``shutdown_complete.set()`` at the
|
||||
very end of its teardown sequence (after stopping targets, flushing
|
||||
stores, and checkpointing the DB). External supervisors — the Windows
|
||||
OS-shutdown guard and the tray's "Shutdown" handler — wait on it so
|
||||
they release Windows / unblock only once cleanup is genuinely done.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
shutdown_complete: threading.Event = threading.Event()
|
||||
@@ -732,21 +732,198 @@ textarea:focus-visible {
|
||||
.ws-url-row .btn { padding: 4px 10px; min-width: 0; flex: 0 0 auto; }
|
||||
.endpoint-label { display: block; font-weight: 600; margin-bottom: 2px; opacity: 0.7; font-size: 0.8em; }
|
||||
|
||||
/* Scene target selector */
|
||||
/* Scene target selector — patch-bay channel slots, paired with the
|
||||
.ds-section[data-ch="cyan"] panel in scene-preset-editor.html.
|
||||
Each item reads like a numbered channel: index · icon plate · name +
|
||||
type chip · remove. Slot indices are rendered via a CSS counter so
|
||||
DOM reorders don't need JS renumbering. */
|
||||
.scene-target-list {
|
||||
--st-ch: var(--ch-cyan, var(--info-color, #00d8ff));
|
||||
counter-reset: st-slot;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
.scene-target-list:empty::before {
|
||||
content: attr(data-empty);
|
||||
display: block;
|
||||
padding: 14px 12px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
border: 1px dashed color-mix(in srgb, var(--st-ch) 40%, var(--lux-line, var(--border-color)));
|
||||
border-radius: var(--lux-r-md, 6px);
|
||||
background:
|
||||
repeating-linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--st-ch) 4%, transparent) 0 6px,
|
||||
transparent 6px 12px);
|
||||
text-align: center;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.scene-target-item {
|
||||
counter-increment: st-slot;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 26px 32px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 8px 6px 6px;
|
||||
border: 1px solid var(--lux-line, var(--border-color));
|
||||
border-radius: var(--lux-r-md, 6px);
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--st-ch) 3%, var(--lux-bg-2, var(--bg-secondary))) 0%,
|
||||
color-mix(in srgb, var(--lux-bg-1, var(--card-bg)) 70%, transparent) 100%);
|
||||
font-size: 0.85rem;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
.scene-target-item:hover {
|
||||
border-color: color-mix(in srgb, var(--st-ch) 55%, var(--lux-line, var(--border-color)));
|
||||
box-shadow:
|
||||
inset 2px 0 0 color-mix(in srgb, var(--st-ch) 80%, transparent),
|
||||
0 1px 0 color-mix(in srgb, var(--st-ch) 14%, transparent);
|
||||
}
|
||||
.scene-target-item::before {
|
||||
content: counter(st-slot, decimal-leading-zero);
|
||||
grid-column: 1;
|
||||
justify-self: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
color: color-mix(in srgb, var(--st-ch) 75%, var(--lux-ink-dim, var(--text-secondary)));
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.scene-target-icon {
|
||||
grid-column: 2;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-secondary);
|
||||
font-size: 0.9rem;
|
||||
justify-content: center;
|
||||
color: var(--st-ch);
|
||||
background: color-mix(in srgb, var(--st-ch) 9%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--st-ch) 22%, transparent);
|
||||
border-radius: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.scene-target-icon svg,
|
||||
.scene-target-icon .icon { width: 18px; height: 18px; }
|
||||
|
||||
.scene-target-id {
|
||||
grid-column: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.scene-target-name {
|
||||
font-weight: 600;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.scene-target-type {
|
||||
align-self: flex-start;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: color-mix(in srgb, var(--st-ch) 70%, var(--lux-ink-dim, var(--text-secondary)));
|
||||
padding: 1px 5px;
|
||||
border: 1px solid color-mix(in srgb, var(--st-ch) 28%, transparent);
|
||||
border-radius: 2px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.scene-target-remove {
|
||||
grid-column: 4;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
.scene-target-remove:hover,
|
||||
.scene-target-remove:focus-visible {
|
||||
opacity: 1;
|
||||
color: var(--ch-coral, var(--danger-color, #ff5e5e));
|
||||
background: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 35%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
.scene-target-remove .icon,
|
||||
.scene-target-remove svg { width: 14px; height: 14px; }
|
||||
|
||||
/* Add-target slot — full-width dashed cyan channel, reads like an
|
||||
empty patch-bay slot waiting for an insertion. */
|
||||
.scene-target-add-slot {
|
||||
--st-ch: var(--ch-cyan, var(--info-color, #00d8ff));
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background:
|
||||
repeating-linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--st-ch) 5%, transparent) 0 6px,
|
||||
transparent 6px 12px);
|
||||
border: 1px dashed color-mix(in srgb, var(--st-ch) 50%, var(--lux-line, var(--border-color)));
|
||||
border-radius: var(--lux-r-md, 6px);
|
||||
color: color-mix(in srgb, var(--st-ch) 80%, var(--lux-ink, var(--text-color)));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
.scene-target-add-slot::before {
|
||||
content: '+';
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.scene-target-add-slot:hover:not(:disabled) {
|
||||
background:
|
||||
repeating-linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--st-ch) 12%, transparent) 0 6px,
|
||||
transparent 6px 12px);
|
||||
border-color: color-mix(in srgb, var(--st-ch) 75%, transparent);
|
||||
color: var(--st-ch);
|
||||
}
|
||||
.scene-target-add-slot:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.scene-target-add-slot:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Icon Select (reusable type picker) ──────────────────────── */
|
||||
@@ -868,6 +1045,79 @@ textarea:focus-visible {
|
||||
border-color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-secondary));
|
||||
}
|
||||
/* ── MiniSelect (icon-less compact dropdown) ── */
|
||||
.mini-select-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
min-height: 28px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, background 120ms ease;
|
||||
}
|
||||
.mini-select-trigger:hover,
|
||||
.mini-select-trigger:focus-visible {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.mini-select-trigger-arrow {
|
||||
opacity: 0.6;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
.mini-select-popup {
|
||||
position: fixed;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
z-index: 9999;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
padding: 4px;
|
||||
min-width: 120px;
|
||||
}
|
||||
.mini-select-popup.open { display: block; }
|
||||
.mini-select-popup:focus,
|
||||
.mini-select-popup:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.mini-select-option {
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
user-select: none;
|
||||
}
|
||||
.mini-select-option:hover {
|
||||
background: color-mix(in srgb, var(--primary-color) 14%, transparent);
|
||||
}
|
||||
.mini-select-option.active {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
.mini-select-option.focused {
|
||||
background: color-mix(in srgb, var(--primary-color) 20%, transparent);
|
||||
box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--primary-color) 45%, transparent);
|
||||
}
|
||||
|
||||
.icon-select-cell.focused {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
}
|
||||
/* When the keyboard cursor lands on the currently-selected cell, intensify
|
||||
the focus ring so the combined active+focused state is unambiguous. */
|
||||
.icon-select-cell.active.focused {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 55%, transparent);
|
||||
}
|
||||
.icon-select-popup:focus,
|
||||
.icon-select-popup:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.icon-select-cell-icon {
|
||||
display: flex;
|
||||
@@ -1451,3 +1701,194 @@ textarea:focus-visible {
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* ── Pair Device Modal ──────────────────────────────────────────
|
||||
Reusable handshake UI for drivers that require a physical pairing
|
||||
action (Nanoleaf, Tuya, Twinkly, …). See `features/pairing-flow.ts`
|
||||
and `modals/pair-device.html`. The instructions stay visible across
|
||||
states, dimming during pairing so the user remains oriented while
|
||||
the progress ring takes focal point. */
|
||||
|
||||
.pair-instructions {
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 18px 0;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.pair-states[data-pair-state="pairing"] ~ .pair-instructions,
|
||||
.pair-states[data-pair-state="pairing"] .pair-instructions {
|
||||
/* Selector kept for posterity — actual dimming is applied via the
|
||||
parent attribute below. */
|
||||
}
|
||||
|
||||
.modal-body:has(.pair-states[data-pair-state="pairing"]) .pair-instructions {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.pair-states {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 18px;
|
||||
padding: 8px 0 4px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.pair-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 18px;
|
||||
width: 100%;
|
||||
animation: pairFade 0.28s ease both;
|
||||
}
|
||||
|
||||
@keyframes pairFade {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.pair-action {
|
||||
min-width: 180px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Idle visual: device-glyph with a soft pulse halo ────────── */
|
||||
|
||||
.pair-visual {
|
||||
position: relative;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pair-visual-glyph {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
color: var(--ch-signal, var(--primary-color));
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.pair-pulse {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
color-mix(in srgb, var(--ch-signal, var(--primary-color)) 28%, transparent),
|
||||
transparent 70%
|
||||
);
|
||||
animation: pairPulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pairPulse {
|
||||
0%, 100% { transform: scale(0.85); opacity: 0.55; }
|
||||
50% { transform: scale(1.08); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Pairing ring + countdown ────────────────────────────────── */
|
||||
|
||||
.pair-ring-wrap {
|
||||
position: relative;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.pair-ring {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* Rotate so the fill starts at 12 o'clock and progresses clockwise. */
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.pair-ring-bg {
|
||||
stroke: color-mix(in srgb, var(--lux-line-bold, var(--border-color)) 60%, transparent);
|
||||
}
|
||||
|
||||
.pair-ring-fg {
|
||||
stroke: var(--ch-signal, var(--primary-color));
|
||||
stroke-dasharray: 0 100;
|
||||
transition: stroke-dasharray 0.18s linear;
|
||||
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 45%, transparent));
|
||||
}
|
||||
|
||||
.pair-ring-content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pair-ring-count {
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 1.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pair-ring-label {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
}
|
||||
|
||||
/* ── Status banners (not-ready / success / failed) ───────────── */
|
||||
|
||||
.pair-status {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--lux-line-bold, var(--border-color));
|
||||
background: color-mix(in srgb, var(--lux-bg-1, var(--card-bg)) 80%, transparent);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
}
|
||||
|
||||
.pair-status .icon {
|
||||
flex: 0 0 auto;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.pair-status-ok {
|
||||
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 12%, transparent);
|
||||
}
|
||||
.pair-status-ok .icon { color: var(--ch-signal, var(--primary-color)); }
|
||||
|
||||
.pair-status-warn {
|
||||
border-color: color-mix(in srgb, var(--ch-amber, #d99a00) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--ch-amber, #d99a00) 12%, transparent);
|
||||
}
|
||||
.pair-status-warn .icon { color: var(--ch-amber, #d99a00); }
|
||||
|
||||
.pair-status-err {
|
||||
border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 12%, transparent);
|
||||
}
|
||||
.pair-status-err .icon { color: var(--ch-coral, var(--danger-color)); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pair-pulse,
|
||||
.pair-state { animation: none; }
|
||||
.pair-ring-fg { transition: none; }
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -147,6 +147,14 @@ export function isDmxDevice(type: string) {
|
||||
return type === 'dmx';
|
||||
}
|
||||
|
||||
export function isDdpDevice(type: string) {
|
||||
return type === 'ddp';
|
||||
}
|
||||
|
||||
export function isOpcDevice(type: string) {
|
||||
return type === 'opc';
|
||||
}
|
||||
|
||||
export function isEspnowDevice(type: string) {
|
||||
return type === 'espnow';
|
||||
}
|
||||
@@ -155,6 +163,26 @@ export function isHueDevice(type: string) {
|
||||
return type === 'hue';
|
||||
}
|
||||
|
||||
export function isYeelightDevice(type: string) {
|
||||
return type === 'yeelight';
|
||||
}
|
||||
|
||||
export function isWizDevice(type: string) {
|
||||
return type === 'wiz';
|
||||
}
|
||||
|
||||
export function isLifxDevice(type: string) {
|
||||
return type === 'lifx';
|
||||
}
|
||||
|
||||
export function isGoveeDevice(type: string) {
|
||||
return type === 'govee';
|
||||
}
|
||||
|
||||
export function isNanoleafDevice(type: string) {
|
||||
return type === 'nanoleaf';
|
||||
}
|
||||
|
||||
export function isUsbhidDevice(type: string) {
|
||||
return type === 'usbhid';
|
||||
}
|
||||
@@ -316,6 +344,13 @@ export async function loadServerInfo() {
|
||||
uptimeSec: data.uptime_seconds,
|
||||
recordedAtPerf: performance.now(),
|
||||
};
|
||||
// Wake the inline transport-uptime ticker immediately instead of
|
||||
// waiting up to a full second for its setInterval tick. Without
|
||||
// this nudge the field can stay on "—" for ~1 s after page load
|
||||
// (and up to 10 s if init's first /health response arrives just
|
||||
// after a tick and the user has to wait for the next connection-
|
||||
// monitor poll to seed it).
|
||||
document.dispatchEvent(new CustomEvent('serverUptimeChanged'));
|
||||
}
|
||||
|
||||
// Demo mode detection
|
||||
|
||||
@@ -20,7 +20,12 @@ export type IconCategory =
|
||||
| 'rooms'
|
||||
| 'media'
|
||||
| 'signal'
|
||||
| 'ambience';
|
||||
| 'ambience'
|
||||
| 'weather'
|
||||
| 'nature'
|
||||
| 'controls'
|
||||
| 'status'
|
||||
| 'office';
|
||||
|
||||
export interface DeviceIconDef {
|
||||
/** Stable identifier — persisted on the entity. */
|
||||
@@ -42,6 +47,11 @@ export const CATEGORIES: { id: IconCategory; label: string; i18n: string }[] = [
|
||||
{ id: 'media', label: 'Media', i18n: 'device.icon.cat.media' },
|
||||
{ id: 'signal', label: 'Signal', i18n: 'device.icon.cat.signal' },
|
||||
{ id: 'ambience', label: 'Ambience', i18n: 'device.icon.cat.ambience' },
|
||||
{ id: 'weather', label: 'Weather', i18n: 'device.icon.cat.weather' },
|
||||
{ id: 'nature', label: 'Nature', i18n: 'device.icon.cat.nature' },
|
||||
{ id: 'controls', label: 'Controls', i18n: 'device.icon.cat.controls' },
|
||||
{ id: 'status', label: 'Status', i18n: 'device.icon.cat.status' },
|
||||
{ id: 'office', label: 'Office', i18n: 'device.icon.cat.office' },
|
||||
];
|
||||
|
||||
export const DEVICE_ICONS: DeviceIconDef[] = [
|
||||
@@ -56,6 +66,16 @@ export const DEVICE_ICONS: DeviceIconDef[] = [
|
||||
{ id: 'headphones', paths: P.headphones, label: 'Headphones', aliases: ['headset', 'cans'], category: 'hardware' },
|
||||
{ id: 'usb', paths: P.usb, label: 'USB', aliases: ['cable', 'connector'], category: 'hardware' },
|
||||
{ id: 'plug', paths: P.plug, label: 'Power plug', aliases: ['outlet', 'socket'], category: 'hardware' },
|
||||
{ id: 'phone', paths: P.smartphone, label: 'Phone', aliases: ['mobile', 'tablet', 'cell', 'smartphone'], category: 'hardware' },
|
||||
{ id: 'package', paths: P.packageIcon, label: 'Device unit', aliases: ['box', 'package', 'module', 'crate'], category: 'hardware' },
|
||||
{ id: 'code', paths: P.code, label: 'Code', aliases: ['script', 'firmware', 'dev'], category: 'hardware' },
|
||||
{ id: 'laptop', paths: P.laptop, label: 'Laptop', aliases: ['notebook', 'macbook', 'computer'], category: 'hardware' },
|
||||
{ id: 'server', paths: P.server, label: 'Server', aliases: ['rack', 'host', 'cluster', 'nas'], category: 'hardware' },
|
||||
{ id: 'router', paths: P.router, label: 'Router', aliases: ['network', 'gateway', 'access point', 'ap'], category: 'hardware' },
|
||||
{ id: 'webcam', paths: P.webcam, label: 'Webcam', aliases: ['cam', 'capture', 'video'], category: 'hardware' },
|
||||
{ id: 'bot', paths: P.bot, label: 'Bot', aliases: ['robot', 'vacuum', 'automation', 'roomba'], category: 'hardware' },
|
||||
{ id: 'watch', paths: P.watch, label: 'Smartwatch', aliases: ['wearable', 'wrist', 'fitness'], category: 'hardware' },
|
||||
{ id: 'mcu', paths: P.memoryStick, label: 'Microcontroller', aliases: ['esp32', 'esp8266', 'arduino', 'mcu', 'chip', 'firmware'], category: 'hardware' },
|
||||
|
||||
// Lighting
|
||||
{ id: 'bulb', paths: P.lightbulb, label: 'Bulb', aliases: ['lamp', 'light', 'lightbulb'], category: 'lighting' },
|
||||
@@ -65,6 +85,15 @@ export const DEVICE_ICONS: DeviceIconDef[] = [
|
||||
{ id: 'lamp', paths: P.flaskConical, label: 'Floor lamp', aliases: ['standing', 'pendant'], category: 'lighting' },
|
||||
{ id: 'power', paths: P.power, label: 'Power', aliases: ['onoff', 'switch', 'standby'], category: 'lighting' },
|
||||
{ id: 'palette', paths: P.palette, label: 'Palette', aliases: ['color', 'colour', 'paint'], category: 'lighting' },
|
||||
{ id: 'dim', paths: P.sunDim, label: 'Dim light', aliases: ['soft', 'low', 'mood'], category: 'lighting' },
|
||||
{ id: 'ring', paths: P.circle, label: 'Ring light', aliases: ['halo', 'round', 'circle'], category: 'lighting' },
|
||||
{ id: 'point', paths: P.circleDot, label: 'Point light', aliases: ['pinpoint', 'dot', 'pixel'], category: 'lighting' },
|
||||
{ id: 'ceiling', paths: P.lampCeiling, label: 'Ceiling light', aliases: ['chandelier', 'pendant', 'hanging'], category: 'lighting' },
|
||||
{ id: 'desk_lamp', paths: P.lampDesk, label: 'Desk lamp', aliases: ['task', 'workspace', 'pixar'], category: 'lighting' },
|
||||
{ id: 'wall_light', paths: P.lampWallUp, label: 'Wall light', aliases: ['sconce', 'uplight', 'mounted'], category: 'lighting' },
|
||||
{ id: 'flashlight', paths: P.flashlight, label: 'Flashlight', aliases: ['torch', 'beam', 'handheld'], category: 'lighting' },
|
||||
{ id: 'bulb_off', paths: P.lightbulbOff, label: 'Bulb off', aliases: ['lightoff', 'dark', 'inactive', 'disabled'], category: 'lighting' },
|
||||
{ id: 'candle', paths: P.candle, label: 'Candle', aliases: ['flame', 'wax', 'ambient', 'romantic'], category: 'lighting' },
|
||||
|
||||
// Rooms
|
||||
{ id: 'bed', paths: P.bed, label: 'Bedroom', aliases: ['sleep', 'bedroom'], category: 'rooms' },
|
||||
@@ -74,6 +103,10 @@ export const DEVICE_ICONS: DeviceIconDef[] = [
|
||||
{ id: 'home', paths: P.home, label: 'Home', aliases: ['house', 'household'], category: 'rooms' },
|
||||
{ id: 'fan', paths: P.fan, label: 'Fan', aliases: ['cooling', 'air'], category: 'rooms' },
|
||||
{ id: 'thermostat', paths: P.thermometer, label: 'Thermostat', aliases: ['temperature', 'heating', 'climate'], category: 'rooms' },
|
||||
{ id: 'kitchen', paths: P.chefHat, label: 'Kitchen', aliases: ['cook', 'chef', 'food'], category: 'rooms' },
|
||||
{ id: 'bath', paths: P.bath, label: 'Bathroom', aliases: ['bath', 'tub', 'shower'], category: 'rooms' },
|
||||
{ id: 'garage', paths: P.warehouse, label: 'Garage', aliases: ['workshop', 'shed', 'storage'], category: 'rooms' },
|
||||
{ id: 'outdoor', paths: P.trees, label: 'Outdoor', aliases: ['garden', 'yard', 'patio', 'park'], category: 'rooms' },
|
||||
|
||||
// Media
|
||||
{ id: 'monitor', paths: P.monitor, label: 'Monitor', aliases: ['display', 'screen'], category: 'media' },
|
||||
@@ -83,6 +116,11 @@ export const DEVICE_ICONS: DeviceIconDef[] = [
|
||||
{ id: 'speaker', paths: P.volume2, label: 'Speaker', aliases: ['audio', 'output', 'monitor'], category: 'media' },
|
||||
{ id: 'music', paths: P.music, label: 'Music', aliases: ['note', 'audio'], category: 'media' },
|
||||
{ id: 'film', paths: P.film, label: 'Film', aliases: ['video', 'movie', 'reel'], category: 'media' },
|
||||
{ id: 'projector', paths: P.projector, label: 'Projector', aliases: ['beamer', 'cinema', 'home theater'], category: 'media' },
|
||||
{ id: 'camcorder', paths: P.video, label: 'Camcorder', aliases: ['video', 'recorder', 'cam'], category: 'media' },
|
||||
{ id: 'disc', paths: P.disc, label: 'Disc', aliases: ['vinyl', 'record', 'cd', 'dvd'], category: 'media' },
|
||||
{ id: 'image', paths: P.image, label: 'Image', aliases: ['picture', 'photo', 'still'], category: 'media' },
|
||||
{ id: 'audio_file', paths: P.fileAudio, label: 'Audio file', aliases: ['sound', 'sample', 'track'], category: 'media' },
|
||||
|
||||
// Signal
|
||||
{ id: 'wifi', paths: P.wifi, label: 'Wi-Fi', aliases: ['wireless', 'network'], category: 'signal' },
|
||||
@@ -91,16 +129,73 @@ export const DEVICE_ICONS: DeviceIconDef[] = [
|
||||
{ id: 'globe', paths: P.globe, label: 'Network', aliases: ['internet', 'web', 'world'], category: 'signal' },
|
||||
{ id: 'cloud', paths: P.cloudSun, label: 'Cloud', aliases: ['weather', 'mqtt'], category: 'signal' },
|
||||
{ id: 'gps', paths: P.mapPin, label: 'Location', aliases: ['map', 'gps', 'pin', 'place'], category: 'signal' },
|
||||
{ id: 'send', paths: P.send, label: 'Send', aliases: ['push', 'publish', 'mqtt', 'transmit'], category: 'signal' },
|
||||
|
||||
// Ambience
|
||||
{ id: 'sun', paths: P.sun, label: 'Sun', aliases: ['daylight', 'sunny', 'bright'], category: 'ambience' },
|
||||
{ id: 'moon', paths: P.moon, label: 'Moon', aliases: ['night', 'dark'], category: 'ambience' },
|
||||
{ id: 'flame', paths: P.flame, label: 'Flame', aliases: ['fire', 'candle', 'warm'], category: 'ambience' },
|
||||
{ id: 'leaf', paths: P.leaf, label: 'Leaf', aliases: ['plant', 'eco', 'nature', 'green'], category: 'ambience' },
|
||||
{ id: 'star', paths: P.star, label: 'Star', aliases: ['favorite', 'special'], category: 'ambience' },
|
||||
{ id: 'sparkles', paths: P.sparkles, label: 'Sparkles', aliases: ['effect', 'magic', 'glow'], category: 'ambience' },
|
||||
{ id: 'gamepad', paths: P.gamepad2, label: 'Game', aliases: ['gaming', 'play'], category: 'ambience' },
|
||||
{ id: 'heart', paths: P.heart, label: 'Heart', aliases: ['love', 'favorite'], category: 'ambience' },
|
||||
|
||||
// Weather
|
||||
{ id: 'rain', paths: P.cloudRain, label: 'Rain', aliases: ['rainy', 'shower', 'drizzle', 'wet'], category: 'weather' },
|
||||
{ id: 'snow', paths: P.cloudSnow, label: 'Snow', aliases: ['snowy', 'blizzard', 'cold'], category: 'weather' },
|
||||
{ id: 'thunder', paths: P.cloudLightning, label: 'Thunder', aliases: ['lightning', 'storm', 'bolt'], category: 'weather' },
|
||||
{ id: 'fog', paths: P.cloudFog, label: 'Fog', aliases: ['mist', 'haze', 'foggy'], category: 'weather' },
|
||||
{ id: 'wind', paths: P.wind, label: 'Wind', aliases: ['breeze', 'gust', 'windy'], category: 'weather' },
|
||||
{ id: 'snowflake', paths: P.snowflake, label: 'Snowflake', aliases: ['frost', 'ice', 'crystal'], category: 'weather' },
|
||||
{ id: 'umbrella', paths: P.umbrella, label: 'Umbrella', aliases: ['rain', 'shelter', 'parasol'], category: 'weather' },
|
||||
{ id: 'sunrise', paths: P.sunrise, label: 'Sunrise', aliases: ['dawn', 'morning', 'sunup'], category: 'weather' },
|
||||
{ id: 'sunset', paths: P.sunset, label: 'Sunset', aliases: ['dusk', 'evening', 'twilight'], category: 'weather' },
|
||||
|
||||
// Nature
|
||||
{ id: 'tree', paths: P.treeDeciduous, label: 'Tree', aliases: ['plant', 'oak', 'forest', 'wood'], category: 'nature' },
|
||||
{ id: 'flower', paths: P.flower2, label: 'Flower', aliases: ['bloom', 'blossom', 'garden'], category: 'nature' },
|
||||
{ id: 'mountain', paths: P.mountain, label: 'Mountain', aliases: ['peak', 'hill', 'summit', 'alpine'], category: 'nature' },
|
||||
{ id: 'waves', paths: P.waves, label: 'Waves', aliases: ['water', 'ocean', 'sea', 'ripple'], category: 'nature' },
|
||||
{ id: 'sprout', paths: P.sprout, label: 'Sprout', aliases: ['seedling', 'shoot', 'grow', 'eco'], category: 'nature' },
|
||||
{ id: 'water_drops', paths: P.droplets, label: 'Water drops', aliases: ['humidity', 'liquid', 'wet', 'splash'], category: 'nature' },
|
||||
{ id: 'leaf', paths: P.leaf, label: 'Leaf', aliases: ['plant', 'eco', 'nature', 'green'], category: 'nature' },
|
||||
|
||||
// Controls
|
||||
{ id: 'switch', paths: P.toggleRight, label: 'Switch', aliases: ['toggle', 'on', 'off'], category: 'controls' },
|
||||
{ id: 'slider', paths: P.slidersHorizontal, label: 'Slider', aliases: ['mixer', 'eq', 'level', 'dimmer'], category: 'controls' },
|
||||
{ id: 'settings', paths: P.settings, label: 'Settings', aliases: ['cog', 'preferences', 'gear'], category: 'controls' },
|
||||
{ id: 'refresh', paths: P.refreshCw, label: 'Refresh', aliases: ['reload', 'sync', 'cycle'], category: 'controls' },
|
||||
{ id: 'undo', paths: P.undo2, label: 'Undo', aliases: ['back', 'revert'], category: 'controls' },
|
||||
{ id: 'trash', paths: P.trash2, label: 'Trash', aliases: ['delete', 'remove', 'bin'], category: 'controls' },
|
||||
{ id: 'link', paths: P.link, label: 'Link', aliases: ['chain', 'connect', 'url'], category: 'controls' },
|
||||
{ id: 'search', paths: P.search, label: 'Search', aliases: ['find', 'magnify', 'lookup'], category: 'controls' },
|
||||
{ id: 'add', paths: P.plus, label: 'Add', aliases: ['plus', 'new', 'create'], category: 'controls' },
|
||||
{ id: 'show', paths: P.eye, label: 'Show', aliases: ['view', 'visible', 'preview'], category: 'controls' },
|
||||
{ id: 'lock', paths: P.lock, label: 'Lock', aliases: ['secure', 'private', 'closed'], category: 'controls' },
|
||||
{ id: 'key', paths: P.keyRound, label: 'Key', aliases: ['credential', 'token', 'auth'], category: 'controls' },
|
||||
{ id: 'tool', paths: P.wrench, label: 'Tool', aliases: ['wrench', 'fix', 'maintenance'], category: 'controls' },
|
||||
|
||||
// Status
|
||||
{ id: 'check', paths: P.check, label: 'Check', aliases: ['done', 'ok', 'tick'], category: 'status' },
|
||||
{ id: 'ok', paths: P.circleCheck, label: 'OK', aliases: ['success', 'valid', 'good'], category: 'status' },
|
||||
{ id: 'warning', paths: P.triangleAlert, label: 'Warning', aliases: ['caution', 'alert', 'attention'], category: 'status' },
|
||||
{ id: 'help', paths: P.circleHelp, label: 'Help', aliases: ['question', 'unknown', 'about'], category: 'status' },
|
||||
{ id: 'off', paths: P.circleOff, label: 'Disabled', aliases: ['disable', 'inactive', 'banned'], category: 'status' },
|
||||
{ id: 'shield', paths: P.shield, label: 'Shield', aliases: ['secure', 'protect', 'guard'], category: 'status' },
|
||||
{ id: 'target', paths: P.target, label: 'Target', aliases: ['goal', 'aim', 'focus'], category: 'status' },
|
||||
{ id: 'pulse', paths: P.activity, label: 'Pulse', aliases: ['activity', 'live', 'heartbeat', 'metrics'], category: 'status' },
|
||||
{ id: 'trend', paths: P.trendingUp, label: 'Trending', aliases: ['rising', 'up', 'graph'], category: 'status' },
|
||||
{ id: 'battery', paths: P.batteryFull, label: 'Battery', aliases: ['power', 'charge', 'cell'], category: 'status' },
|
||||
|
||||
// Office
|
||||
{ id: 'calendar', paths: P.calendar, label: 'Calendar', aliases: ['date', 'schedule', 'event'], category: 'office' },
|
||||
{ id: 'mail', paths: P.mail, label: 'Mail', aliases: ['email', 'envelope', 'message'], category: 'office' },
|
||||
{ id: 'coffee', paths: P.coffee, label: 'Coffee', aliases: ['drink', 'cafe', 'break'], category: 'office' },
|
||||
{ id: 'briefcase', paths: P.briefcase, label: 'Briefcase', aliases: ['work', 'business', 'job'], category: 'office' },
|
||||
{ id: 'doc', paths: P.fileText, label: 'Document', aliases: ['file', 'paper', 'text'], category: 'office' },
|
||||
{ id: 'checklist', paths: P.clipboardList, label: 'Checklist', aliases: ['list', 'tasks', 'todo'], category: 'office' },
|
||||
{ id: 'hashtag', paths: P.hash, label: 'Hashtag', aliases: ['tag', 'number', 'channel'], category: 'office' },
|
||||
{ id: 'clock', paths: P.clock, label: 'Clock', aliases: ['time', 'hour', 'minute'], category: 'office' },
|
||||
];
|
||||
|
||||
const _byId: Record<string, DeviceIconDef> = Object.fromEntries(
|
||||
|
||||
@@ -14,6 +14,55 @@ import { showRestartingOverlay } from './api.ts';
|
||||
import { logError } from './log.ts';
|
||||
import { openAuthedWs } from './ws-auth.ts';
|
||||
|
||||
/**
|
||||
* Allowed ``type`` values on inbound server-event messages. Anything outside
|
||||
* this list is rejected before dispatch so a malformed message can't
|
||||
* synthesise an arbitrary ``server:*`` CustomEvent. New event types must be
|
||||
* added here intentionally — the server is the schema's source of truth.
|
||||
*
|
||||
* Audit (matches Python sources for ``fire_event`` / ``_fire_event`` /
|
||||
* ``self._emit`` / ``fire_entity_event`` call sites — see the parity
|
||||
* regression test in ``server/tests/test_events_ws_parity.py``):
|
||||
* server_restarting — server_ref.py / update_service.py
|
||||
* state_change — wled_target_processor.py / auto_restart.py
|
||||
* automation_state_changed — automation_engine.py
|
||||
* entity_changed — dependencies.fire_entity_event
|
||||
* device_health_changed — device_health.py
|
||||
* update_available — update_service.py (consumed by features/update.ts)
|
||||
* update_download_progress — update_service.py (consumed by features/update.ts)
|
||||
* device_discovered — discovery_watcher.py (consumed by features/notifications-watcher.ts)
|
||||
* device_lost — discovery_watcher.py (consumed by features/notifications-watcher.ts)
|
||||
*
|
||||
* Missing any of these silently breaks the corresponding UI flow — keep
|
||||
* this list in sync when adding new event types on the server side.
|
||||
*/
|
||||
const _ALLOWED_SERVER_EVENT_TYPES: ReadonlySet<string> = new Set([
|
||||
'server_restarting',
|
||||
'state_change',
|
||||
'automation_state_changed',
|
||||
'entity_changed',
|
||||
'device_health_changed',
|
||||
'update_available',
|
||||
'update_download_progress',
|
||||
'device_discovered',
|
||||
'device_lost',
|
||||
]);
|
||||
|
||||
interface ServerEventEnvelope {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function _isServerEventEnvelope(value: unknown): value is ServerEventEnvelope {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const t = (value as { type?: unknown }).type;
|
||||
if (typeof t !== 'string' || !_ALLOWED_SERVER_EVENT_TYPES.has(t)) return false;
|
||||
// Event-name character set: identifiers only. CustomEvent names can be
|
||||
// anything but pinning them keeps the listener namespace predictable.
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(t)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True when the server has signalled it is restarting (not crashed). */
|
||||
export let serverRestarting = false;
|
||||
|
||||
@@ -40,7 +89,15 @@ export function startEventsWS() {
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
const data: unknown = JSON.parse(event.data);
|
||||
// Validate the envelope before we dispatch — without this,
|
||||
// a malformed/hostile server message becomes an arbitrary
|
||||
// ``server:*`` CustomEvent on document, which feature
|
||||
// listeners then trust.
|
||||
if (!_isServerEventEnvelope(data)) {
|
||||
logError('events-ws.message', `Discarded malformed server message`);
|
||||
return;
|
||||
}
|
||||
if (data.type === 'server_restarting') {
|
||||
serverRestarting = true;
|
||||
showRestartingOverlay();
|
||||
@@ -53,7 +110,9 @@ export function startEventsWS() {
|
||||
_reconnectTimer = setTimeout(startEventsWS, _reconnectDelay);
|
||||
_reconnectDelay = Math.min(_reconnectDelay * 2, _RECONNECT_MAX);
|
||||
};
|
||||
ws.onerror = () => {};
|
||||
ws.onerror = (err) => {
|
||||
logError('events-ws.onerror', err);
|
||||
};
|
||||
}).catch(() => {
|
||||
_ws = null;
|
||||
_reconnectTimer = setTimeout(startEventsWS, _reconnectDelay);
|
||||
|
||||
@@ -23,6 +23,7 @@ export const flaskConical = '<path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0
|
||||
export const pencil = '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>';
|
||||
export const play = '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>';
|
||||
export const square = '<rect width="18" height="18" x="3" y="3" rx="2"/>';
|
||||
export const hexagon = '<path d="M21 16.5c0 .38-.21.71-.53.88l-7.9 4.44a1.13 1.13 0 0 1-1.14 0l-7.9-4.44A1 1 0 0 1 3 16.5v-9c0-.38.21-.71.53-.88l7.9-4.44a1.13 1.13 0 0 1 1.14 0l7.9 4.44c.32.17.53.5.53.88z"/>';
|
||||
export const circle = '<circle cx="12" cy="12" r="9"/>';
|
||||
export const pause = '<rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/>';
|
||||
export const settings = '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/>';
|
||||
@@ -143,3 +144,61 @@ export const easingIn = '<path d="M4 20C13 20 16 18 20 4"/>';
|
||||
export const easingOut = '<path d="M4 20C8 6 11 4 20 4"/>';
|
||||
export const easingInOut = '<path d="M4 20C12 20 12 4 20 4"/>';
|
||||
export const easingSine = '<path d="M4 12C7 12 8 4 12 4S17 12 20 12"/>';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Card-icon picker expansion — additional Lucide icons for the
|
||||
// custom card-icon selector. These are surfaced through
|
||||
// device-icons.ts but don't have type-resolution roles.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Weather
|
||||
export const cloudRain = '<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/><path d="M16 14v6"/><path d="M8 14v6"/><path d="M12 16v6"/>';
|
||||
export const cloudSnow = '<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/><path d="M8 15h.01"/><path d="M8 19h.01"/><path d="M12 17h.01"/><path d="M12 21h.01"/><path d="M16 15h.01"/><path d="M16 19h.01"/>';
|
||||
export const cloudLightning = '<path d="M6 16.326A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 .5 8.973"/><path d="m13 12-3 5h4l-3 5"/>';
|
||||
export const cloudFog = '<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/><path d="M16 17H7"/><path d="M17 21H9"/>';
|
||||
export const wind = '<path d="M12.8 19.6A2 2 0 1 0 14 16H2"/><path d="M17.5 8a2.5 2.5 0 1 1 2 4H2"/><path d="M9.8 4.4A2 2 0 1 1 11 8H2"/>';
|
||||
export const snowflake = '<line x1="2" x2="22" y1="12" y2="12"/><line x1="12" x2="12" y1="2" y2="22"/><path d="m20 16-4-4 4-4"/><path d="m4 8 4 4-4 4"/><path d="m16 4-4 4-4-4"/><path d="m8 20 4-4 4 4"/>';
|
||||
export const umbrella = '<path d="M22 12a10.06 10.06 0 0 0-20 0Z"/><path d="M12 12v8a2 2 0 0 0 4 0"/><path d="M12 2v1"/>';
|
||||
export const sunrise = '<path d="M12 2v8"/><path d="m4.93 10.93 1.41 1.41"/><path d="M2 18h2"/><path d="M20 18h2"/><path d="m19.07 10.93-1.41 1.41"/><path d="M22 22H2"/><path d="m8 6 4-4 4 4"/><path d="M16 18a4 4 0 0 0-8 0"/>';
|
||||
export const sunset = '<path d="M12 10V2"/><path d="m4.93 10.93 1.41 1.41"/><path d="M2 18h2"/><path d="M20 18h2"/><path d="m19.07 10.93-1.41 1.41"/><path d="M22 22H2"/><path d="m16 6-4 4-4-4"/><path d="M16 18a4 4 0 0 0-8 0"/>';
|
||||
|
||||
// Nature
|
||||
export const treeDeciduous = '<path d="M8 19a4 4 0 0 1-2.24-7.32A3.5 3.5 0 0 1 9 6.03V6a3 3 0 1 1 6 0v.04a3.5 3.5 0 0 1 3.24 5.65A4 4 0 0 1 16 19Z"/><path d="M12 19v3"/>';
|
||||
export const flower2 = '<circle cx="12" cy="8" r="3"/><path d="M12 11v11"/><path d="M5 8a7 7 0 0 1 14 0"/><path d="M12 22c4.2 0 7-1.667 7-5-4.2 0-7 1.667-7 5Z"/><path d="M12 22c-4.2 0-7-1.667-7-5 4.2 0 7 1.667 7 5Z"/>';
|
||||
export const mountain = '<path d="m8 3 4 8 5-5 5 15H2L8 3z"/>';
|
||||
export const waves = '<path d="M2 6c.6.5 1.2 1 2.5 1C7 7 7 5 9.5 5c2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/><path d="M2 12c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/><path d="M2 18c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/>';
|
||||
export const sprout = '<path d="M7 20h10"/><path d="M10 20c5.5-2.5.8-6.4 3-10"/><path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z"/><path d="M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z"/>';
|
||||
export const trees = '<path d="M10 10v.2A3 3 0 0 1 8.9 16H5a3 3 0 0 1-1-5.8V10a3 3 0 0 1 6 0Z"/><path d="M7 16v6"/><path d="M13 19h6"/><path d="M14 22V13"/><path d="M18 13a4 4 0 0 0 0-8 6 6 0 0 0-9.5-4"/>';
|
||||
|
||||
// Rooms (extended)
|
||||
export const chefHat = '<path d="M17 21a1 1 0 0 0 1-1v-5.35c0-.457.316-.844.727-1.041a4 4 0 0 0-2.134-7.589 5 5 0 0 0-9.186 0 4 4 0 0 0-2.134 7.588c.411.198.727.585.727 1.041V20a1 1 0 0 0 1 1Z"/><path d="M6 17h12"/>';
|
||||
export const bath = '<path d="M9 6 6.5 3.5a1.5 1.5 0 0 0-1-.5C4.683 3 4 3.683 4 4.5V17a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"/><line x1="10" x2="8" y1="5" y2="7"/><line x1="2" x2="22" y1="12" y2="12"/><line x1="7" x2="7" y1="19" y2="21"/><line x1="17" x2="17" y1="19" y2="21"/>';
|
||||
export const warehouse = '<path d="M22 8.35V20a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8.35A2 2 0 0 1 3.26 6.5l8-3.2a2 2 0 0 1 1.48 0l8 3.2A2 2 0 0 1 22 8.35Z"/><path d="M6 18h12"/><path d="M6 14h12"/><path d="M6 18v-8h12v8"/>';
|
||||
|
||||
// Office
|
||||
export const calendar = '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>';
|
||||
export const mail = '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>';
|
||||
export const coffee = '<path d="M10 2v2"/><path d="M14 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/><path d="M6 2v2"/>';
|
||||
export const briefcase = '<path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/><rect width="20" height="14" x="2" y="6" rx="2"/>';
|
||||
|
||||
// Media (extended)
|
||||
export const video = '<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5"/><rect x="2" y="6" width="14" height="12" rx="2"/>';
|
||||
export const disc = '<circle cx="12" cy="12" r="10"/><path d="M6 12c0-1.7.7-3.2 1.8-4.2"/><circle cx="12" cy="12" r="2"/><path d="M18 12c0 1.7-.7 3.2-1.8 4.2"/>';
|
||||
export const projector = '<path d="M5 7 3 5"/><path d="M9 6V3"/><path d="m13 7 2-2"/><circle cx="9" cy="13" r="3"/><path d="M11.83 12H20a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h.17"/><path d="M16 16h2"/>';
|
||||
|
||||
// Hardware (extended)
|
||||
export const laptop = '<rect width="18" height="12" x="3" y="4" rx="2" ry="2"/><line x1="2" x2="22" y1="20" y2="20"/>';
|
||||
export const server = '<rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/>';
|
||||
export const router = '<rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6.01 18H6"/><path d="M10.01 18H10"/><path d="M15 10v4"/><path d="M17.84 7.17a4 4 0 0 0-5.66 0"/><path d="M20.66 4.34a8 8 0 0 0-11.31 0"/>';
|
||||
export const webcam = '<circle cx="12" cy="10" r="8"/><circle cx="12" cy="10" r="3"/><path d="M7 22h10"/><path d="M12 22v-4"/>';
|
||||
export const bot = '<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>';
|
||||
export const watch = '<circle cx="12" cy="12" r="6"/><polyline points="12 10 12 12 13 13"/><path d="m16.13 7.66-.81-4.05a2 2 0 0 0-2-1.61h-2.68a2 2 0 0 0-2 1.61l-.78 4.05"/><path d="m7.88 16.36.8 4a2 2 0 0 0 2 1.61h2.72a2 2 0 0 0 2-1.61l.81-4.05"/>';
|
||||
export const memoryStick = '<path d="M6 19v-3"/><path d="M10 19v-3"/><path d="M14 19v-3"/><path d="M18 19v-3"/><path d="M8 11V9"/><path d="M16 11V9"/><path d="M12 11V9"/><path d="M2 15h20"/><path d="M2 7a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v1.1a2 2 0 0 0 0 3.837V17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-5.1a2 2 0 0 0 0-3.837Z"/>';
|
||||
|
||||
// Lighting (extended)
|
||||
export const lampCeiling = '<path d="M12 2v5"/><path d="M6 7h12l-2 5H8z"/><path d="M9.17 12a3 3 0 1 0 5.66 0"/>';
|
||||
export const lampDesk = '<path d="m18 1-3 9"/><path d="m14 5 9 3"/><path d="M16 6 6 16l4 4 10-10"/><path d="M3 22v-2c0-1.1.9-2 2-2h4a2 2 0 0 1 2 2v2H3Z"/>';
|
||||
export const lampWallUp = '<path d="M11 4h6l3 7H8z"/><path d="M14 11v5a2 2 0 0 1-2 2H8"/><path d="M4 15v-3a6 6 0 0 1 6-6"/>';
|
||||
export const flashlight = '<path d="M18 6c0 2-2 2-2 4v10a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V10c0-2-2-2-2-4V2h12z"/><line x1="6" x2="18" y1="6" y2="6"/><line x1="12" x2="12" y1="12" y2="12"/>';
|
||||
export const lightbulbOff = '<path d="M16.8 11.2c.8-.9 1.2-2 1.2-3.2a6 6 0 0 0-9.3-5"/><path d="m2 2 20 20"/><path d="M6.3 6.3a4.67 4.67 0 0 0 1.2 5.2c.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>';
|
||||
export const candle = '<path d="M3 14v6a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1v-6"/><path d="M7 14V8a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v6"/><path d="M12 6V4"/><path d="M12 2a1 1 0 0 1 1 1c0 1-1 1.5-1 2.5 0-1-1-1.5-1-2.5a1 1 0 0 1 1-1z"/>';
|
||||
|
||||
@@ -19,17 +19,42 @@
|
||||
*/
|
||||
|
||||
import { desktopFocus } from './ui.ts';
|
||||
import { escapeHtml } from './api.ts';
|
||||
|
||||
const POPUP_CLASS = 'icon-select-popup';
|
||||
const FOCUSED_CLASS = 'focused';
|
||||
const FOCUSED_SELECTOR = `.icon-select-cell.${FOCUSED_CLASS}`;
|
||||
const CELL_SELECTOR = '.icon-select-cell';
|
||||
const NAVIGABLE_SELECTOR = '.icon-select-cell:not(.disabled)';
|
||||
|
||||
/** Close every open icon-select popup. */
|
||||
export function closeAllIconSelects() {
|
||||
document.querySelectorAll(`.${POPUP_CLASS}`).forEach(p => {
|
||||
(p as HTMLElement).classList.remove('open');
|
||||
});
|
||||
/**
|
||||
* Escape a value for use inside a double-quoted HTML attribute.
|
||||
* `escapeHtml` (text-content escape) does not escape `"`, which leaves a
|
||||
* stored-XSS vector when interpolating user-typed labels into attribute
|
||||
* contexts like `data-value="${value}"`. This belt-and-braces helper
|
||||
* covers ``& < > " '`` so the result is safe in any attribute slot.
|
||||
*/
|
||||
function escAttr(text: string | undefined | null): string {
|
||||
if (text == null) return '';
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Global click-away listener (registered once)
|
||||
/** All registered IconSelect instances; lets `closeAllIconSelects` reach scroll-listener state. */
|
||||
const _registry: Set<IconSelect> = new Set();
|
||||
|
||||
/** Close every open icon-select popup (and tear down their scroll listeners). */
|
||||
export function closeAllIconSelects() {
|
||||
for (const sel of _registry) {
|
||||
sel._closeIfOpen();
|
||||
}
|
||||
}
|
||||
|
||||
// Global listeners (registered once)
|
||||
let _globalListenerAdded = false;
|
||||
function _ensureGlobalListener() {
|
||||
if (_globalListenerAdded) return;
|
||||
@@ -64,6 +89,79 @@ export interface IconSelectOpts {
|
||||
searchPlaceholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a keyboard cursor over a grid of cells.
|
||||
*
|
||||
* Returns the new focused index (clamped). Marks the chosen cell with the
|
||||
* shared `.focused` class and scrolls it into view.
|
||||
*/
|
||||
function applyFocus(grid: HTMLElement, cells: HTMLElement[], idx: number): number {
|
||||
grid.querySelectorAll(FOCUSED_SELECTOR).forEach(c => c.classList.remove(FOCUSED_CLASS));
|
||||
if (cells.length === 0) return -1;
|
||||
const clamped = Math.max(0, Math.min(idx, cells.length - 1));
|
||||
cells[clamped].classList.add(FOCUSED_CLASS);
|
||||
cells[clamped].scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
return clamped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the column count of a CSS grid by comparing `offsetTop` of cells
|
||||
* in the first row. Triggers a layout read — callers should cache the result
|
||||
* and invalidate only when the grid is rebuilt or filtered.
|
||||
*/
|
||||
function detectColumns(cells: HTMLElement[]): number {
|
||||
if (cells.length === 0) return 1;
|
||||
const firstTop = cells[0].offsetTop;
|
||||
let cols = 0;
|
||||
for (const c of cells) {
|
||||
if (c.offsetTop !== firstTop) break;
|
||||
cols++;
|
||||
}
|
||||
return Math.max(1, cols);
|
||||
}
|
||||
|
||||
interface GridNavAction {
|
||||
/** New focused index, or -1 to leave focus unchanged. */
|
||||
nextIndex: number;
|
||||
/** True when the key was consumed (caller should preventDefault). */
|
||||
handled: boolean;
|
||||
/** True when Enter was pressed and a cell should be picked. */
|
||||
pick: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure keyboard-nav state machine shared by IconSelect and the standalone
|
||||
* type-picker overlay. Returns what should happen; the caller decides
|
||||
* preventDefault, stopPropagation, and cell-pick wiring.
|
||||
*/
|
||||
function handleGridKey(
|
||||
key: string,
|
||||
cur: number,
|
||||
cellCount: number,
|
||||
columns: number,
|
||||
): GridNavAction {
|
||||
if (cellCount === 0) return { nextIndex: -1, handled: false, pick: false };
|
||||
const safe = cur >= 0 && cur < cellCount ? cur : 0;
|
||||
switch (key) {
|
||||
case 'ArrowRight':
|
||||
return { nextIndex: Math.min(safe + 1, cellCount - 1), handled: true, pick: false };
|
||||
case 'ArrowLeft':
|
||||
return { nextIndex: Math.max(safe - 1, 0), handled: true, pick: false };
|
||||
case 'ArrowDown':
|
||||
return { nextIndex: Math.min(safe + columns, cellCount - 1), handled: true, pick: false };
|
||||
case 'ArrowUp':
|
||||
return { nextIndex: Math.max(safe - columns, 0), handled: true, pick: false };
|
||||
case 'Home':
|
||||
return { nextIndex: 0, handled: true, pick: false };
|
||||
case 'End':
|
||||
return { nextIndex: cellCount - 1, handled: true, pick: false };
|
||||
case 'Enter':
|
||||
return { nextIndex: safe, handled: true, pick: true };
|
||||
default:
|
||||
return { nextIndex: -1, handled: false, pick: false };
|
||||
}
|
||||
}
|
||||
|
||||
export class IconSelect {
|
||||
_select: HTMLSelectElement;
|
||||
_items: IconSelectItem[];
|
||||
@@ -77,6 +175,7 @@ export class IconSelect {
|
||||
_searchInput: HTMLInputElement | null = null;
|
||||
_scrollHandler: (() => void) | null = null;
|
||||
_scrollTargets: (HTMLElement | Window)[] = [];
|
||||
_focusedIndex: number = -1;
|
||||
|
||||
constructor({ target, items, onChange, columns = 2, placeholder = '', searchable = false, searchPlaceholder = 'Filter…' }: IconSelectOpts) {
|
||||
_ensureGlobalListener();
|
||||
@@ -109,6 +208,8 @@ export class IconSelect {
|
||||
this._trigger = document.createElement('button');
|
||||
this._trigger.type = 'button';
|
||||
this._trigger.className = 'icon-select-trigger';
|
||||
this._trigger.setAttribute('aria-haspopup', 'listbox');
|
||||
this._trigger.setAttribute('aria-expanded', 'false');
|
||||
this._trigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._toggle();
|
||||
@@ -118,7 +219,10 @@ export class IconSelect {
|
||||
// Build popup (portaled to body to avoid overflow clipping)
|
||||
this._popup = document.createElement('div');
|
||||
this._popup.className = POPUP_CLASS;
|
||||
this._popup.tabIndex = -1;
|
||||
this._popup.setAttribute('role', 'listbox');
|
||||
this._popup.addEventListener('click', (e) => e.stopPropagation());
|
||||
this._popup.addEventListener('keydown', (e) => this._handleKeydown(e));
|
||||
this._popup.innerHTML = this._buildGrid();
|
||||
document.body.appendChild(this._popup);
|
||||
|
||||
@@ -126,15 +230,17 @@ export class IconSelect {
|
||||
|
||||
// Sync to current select value
|
||||
this._syncTrigger();
|
||||
|
||||
_registry.add(this);
|
||||
}
|
||||
|
||||
_bindPopupEvents() {
|
||||
// Bind item clicks
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
this._popup.querySelectorAll(CELL_SELECTOR).forEach(cell => {
|
||||
cell.setAttribute('role', 'option');
|
||||
cell.addEventListener('click', () => {
|
||||
this.setValue((cell as HTMLElement).dataset.value!, true);
|
||||
this._popup.classList.remove('open');
|
||||
this._removeScrollListener();
|
||||
this._closeIfOpen();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,26 +249,68 @@ export class IconSelect {
|
||||
if (this._searchInput) {
|
||||
this._searchInput.addEventListener('input', () => {
|
||||
const q = this._searchInput!.value.toLowerCase().trim();
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
this._popup.querySelectorAll(CELL_SELECTOR).forEach(cell => {
|
||||
const el = cell as HTMLElement;
|
||||
el.classList.toggle('disabled', !!q && !el.dataset.search!.includes(q));
|
||||
});
|
||||
// Re-anchor keyboard cursor to first visible cell after filtering
|
||||
this._setFocusedIndex(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Cells eligible for keyboard navigation (selectable + visible). */
|
||||
_getNavigableCells(): HTMLElement[] {
|
||||
return Array.from(this._popup.querySelectorAll<HTMLElement>(NAVIGABLE_SELECTOR));
|
||||
}
|
||||
|
||||
/** Move the keyboard cursor to cell `idx` (clamped), scrolling it into view. */
|
||||
_setFocusedIndex(idx: number) {
|
||||
const cells = this._getNavigableCells();
|
||||
this._focusedIndex = applyFocus(this._popup, cells, idx);
|
||||
if (this._focusedIndex >= 0) {
|
||||
const activeId = cells[this._focusedIndex].id || `icon-select-cell-${this._focusedIndex}`;
|
||||
cells[this._focusedIndex].id = activeId;
|
||||
this._popup.setAttribute('aria-activedescendant', activeId);
|
||||
} else {
|
||||
this._popup.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
}
|
||||
|
||||
_handleKeydown(e: KeyboardEvent) {
|
||||
if (!this._popup.classList.contains('open')) return;
|
||||
const cells = this._getNavigableCells();
|
||||
const action = handleGridKey(e.key, this._focusedIndex, cells.length, this._columns);
|
||||
if (!action.handled) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (action.pick) {
|
||||
const cell = cells[action.nextIndex];
|
||||
if (!cell) return;
|
||||
this.setValue(cell.dataset.value!, true);
|
||||
this._closeIfOpen();
|
||||
desktopFocus(this._trigger);
|
||||
return;
|
||||
}
|
||||
this._setFocusedIndex(action.nextIndex);
|
||||
}
|
||||
|
||||
_buildGrid() {
|
||||
// item.icon is a raw SVG string by design (callers pass project-owned
|
||||
// icon literals). label/desc/value are user-visible text and may
|
||||
// originate from user input — escape them everywhere they cross
|
||||
// an innerHTML boundary.
|
||||
const cells = this._items.map(item => {
|
||||
const search = (item.label + ' ' + (item.desc || '')).toLowerCase();
|
||||
return `<div class="icon-select-cell" data-value="${item.value}" data-search="${search}">
|
||||
return `<div class="icon-select-cell" data-value="${escAttr(item.value)}" data-search="${escAttr(search)}">
|
||||
<span class="icon-select-cell-icon">${item.icon}</span>
|
||||
<span class="icon-select-cell-label">${item.label}</span>
|
||||
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''}
|
||||
<span class="icon-select-cell-label">${escapeHtml(item.label)}</span>
|
||||
${item.desc ? `<span class="icon-select-cell-desc">${escapeHtml(item.desc)}</span>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const searchHTML = this._searchable
|
||||
? `<input class="icon-select-search" type="text" placeholder="${this._searchPlaceholder}" autocomplete="off">`
|
||||
? `<input class="icon-select-search" type="text" placeholder="${escAttr(this._searchPlaceholder)}" autocomplete="off">`
|
||||
: '';
|
||||
return searchHTML + `<div class="icon-select-grid" style="grid-template-columns:repeat(${this._columns},1fr)">${cells}</div>`;
|
||||
}
|
||||
@@ -173,17 +321,19 @@ export class IconSelect {
|
||||
if (item) {
|
||||
this._trigger.innerHTML =
|
||||
`<span class="icon-select-trigger-icon">${item.icon}</span>` +
|
||||
`<span class="icon-select-trigger-label">${item.label}</span>` +
|
||||
`<span class="icon-select-trigger-label">${escapeHtml(item.label)}</span>` +
|
||||
`<span class="icon-select-trigger-arrow">▾</span>`;
|
||||
} else if (this._placeholder) {
|
||||
this._trigger.innerHTML =
|
||||
`<span class="icon-select-trigger-label">${this._placeholder}</span>` +
|
||||
`<span class="icon-select-trigger-label">${escapeHtml(this._placeholder)}</span>` +
|
||||
`<span class="icon-select-trigger-arrow">▾</span>`;
|
||||
}
|
||||
// Update active state in grid
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
this._popup.querySelectorAll(CELL_SELECTOR).forEach(cell => {
|
||||
const el = cell as HTMLElement;
|
||||
el.classList.toggle('active', el.dataset.value === val);
|
||||
const active = el.dataset.value === val;
|
||||
el.classList.toggle('active', active);
|
||||
el.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -225,23 +375,46 @@ export class IconSelect {
|
||||
if (!wasOpen) {
|
||||
this._positionPopup();
|
||||
this._popup.classList.add('open');
|
||||
this._trigger.setAttribute('aria-expanded', 'true');
|
||||
this._addScrollListener();
|
||||
if (this._searchInput) {
|
||||
this._searchInput.value = '';
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
this._popup.querySelectorAll(CELL_SELECTOR).forEach(cell => {
|
||||
(cell as HTMLElement).classList.remove('disabled');
|
||||
});
|
||||
requestAnimationFrame(() => desktopFocus(this._searchInput!));
|
||||
} else {
|
||||
// No search input — focus the popup itself so it captures keydown
|
||||
requestAnimationFrame(() => desktopFocus(this._popup));
|
||||
}
|
||||
// Seed keyboard cursor on the currently-selected cell (or first cell)
|
||||
const cells = this._getNavigableCells();
|
||||
const activeIdx = cells.findIndex(c => c.dataset.value === this._select.value);
|
||||
this._setFocusedIndex(activeIdx >= 0 ? activeIdx : 0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the popup if it is open and tear down listeners / focus state. */
|
||||
_closeIfOpen() {
|
||||
if (!this._popup.classList.contains('open')) return;
|
||||
this._popup.classList.remove('open');
|
||||
this._trigger.setAttribute('aria-expanded', 'false');
|
||||
this._removeScrollListener();
|
||||
this._clearFocusedCell();
|
||||
}
|
||||
|
||||
_clearFocusedCell() {
|
||||
this._popup.querySelectorAll(FOCUSED_SELECTOR)
|
||||
.forEach(c => c.classList.remove(FOCUSED_CLASS));
|
||||
this._focusedIndex = -1;
|
||||
this._popup.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
|
||||
/** Close popup when any scrollable ancestor scrolls (prevents stale position). */
|
||||
_addScrollListener() {
|
||||
if (this._scrollHandler) return;
|
||||
this._scrollHandler = () => {
|
||||
this._popup.classList.remove('open');
|
||||
this._removeScrollListener();
|
||||
this._closeIfOpen();
|
||||
};
|
||||
// Listen on capture phase to catch scroll on any ancestor
|
||||
let el: Node | null = this._trigger.parentNode;
|
||||
@@ -289,6 +462,7 @@ export class IconSelect {
|
||||
/** Remove the enhancement, restore native <select>. */
|
||||
destroy() {
|
||||
this._removeScrollListener();
|
||||
_registry.delete(this);
|
||||
this._trigger.remove();
|
||||
this._popup.remove();
|
||||
this._select.style.display = '';
|
||||
@@ -317,11 +491,14 @@ export function showTypePicker({ title, items, onPick, filterTabs, onFilterChang
|
||||
const showFilter = items.length > 9;
|
||||
|
||||
function buildCells(cellItems: IconSelectItem[]): string {
|
||||
// item.icon is trusted raw SVG. label/desc/value are escaped at
|
||||
// every innerHTML boundary because callers route user-typed text
|
||||
// (device names, entity labels) through this picker.
|
||||
return cellItems.map(item =>
|
||||
`<div class="icon-select-cell" data-value="${item.value}" data-search="${(item.label + ' ' + (item.desc || '')).toLowerCase()}">
|
||||
`<div class="icon-select-cell" data-value="${escAttr(item.value)}" data-search="${escAttr((item.label + ' ' + (item.desc || '')).toLowerCase())}" role="option">
|
||||
<span class="icon-select-cell-icon">${item.icon}</span>
|
||||
<span class="icon-select-cell-label">${item.label}</span>
|
||||
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''}
|
||||
<span class="icon-select-cell-label">${escapeHtml(item.label)}</span>
|
||||
${item.desc ? `<span class="icon-select-cell-desc">${escapeHtml(item.desc)}</span>` : ''}
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
@@ -329,7 +506,7 @@ export function showTypePicker({ title, items, onPick, filterTabs, onFilterChang
|
||||
// Build filter tabs HTML
|
||||
const tabsHtml = filterTabs && filterTabs.length > 0
|
||||
? `<div class="type-picker-tabs">${filterTabs.map((tab, i) =>
|
||||
`<button class="type-picker-tab${i === 0 ? ' active' : ''}" data-filter-key="${tab.key}">${tab.label}</button>`
|
||||
`<button class="type-picker-tab${i === 0 ? ' active' : ''}" data-filter-key="${escAttr(tab.key)}">${escapeHtml(tab.label)}</button>`
|
||||
).join('')}</div>`
|
||||
: '';
|
||||
|
||||
@@ -337,20 +514,46 @@ export function showTypePicker({ title, items, onPick, filterTabs, onFilterChang
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'type-picker-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="type-picker-dialog">
|
||||
<div class="type-picker-title">${title}</div>
|
||||
<div class="type-picker-dialog" role="dialog" aria-modal="true" aria-label="${escAttr(title)}">
|
||||
<div class="type-picker-title">${escapeHtml(title)}</div>
|
||||
${tabsHtml}
|
||||
${showFilter ? '<input class="type-picker-filter" type="text" placeholder="Filter…" autocomplete="off">' : ''}
|
||||
<div class="icon-select-grid">${buildCells(items)}</div>
|
||||
<div class="icon-select-grid" role="listbox">${buildCells(items)}</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const close = () => { overlay.remove(); document.removeEventListener('keydown', onKey); };
|
||||
|
||||
const grid = overlay.querySelector('.icon-select-grid') as HTMLElement;
|
||||
const filterInput = (showFilter
|
||||
? overlay.querySelector('.type-picker-filter') as HTMLInputElement
|
||||
: null);
|
||||
|
||||
let focusedIdx = -1;
|
||||
// Cache the column count; recomputed only when the grid is rebuilt or filtered.
|
||||
let cachedColumns = 1;
|
||||
|
||||
const getNavCells = (): HTMLElement[] =>
|
||||
Array.from(grid.querySelectorAll<HTMLElement>(NAVIGABLE_SELECTOR));
|
||||
|
||||
const refreshColumns = () => {
|
||||
cachedColumns = detectColumns(getNavCells());
|
||||
};
|
||||
|
||||
const setFocused = (idx: number) => {
|
||||
const cells = getNavCells();
|
||||
focusedIdx = applyFocus(grid, cells, idx);
|
||||
if (focusedIdx >= 0) {
|
||||
const id = cells[focusedIdx].id || `type-picker-cell-${focusedIdx}`;
|
||||
cells[focusedIdx].id = id;
|
||||
grid.setAttribute('aria-activedescendant', id);
|
||||
} else {
|
||||
grid.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
};
|
||||
|
||||
function bindCellClicks() {
|
||||
grid.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
grid.querySelectorAll(CELL_SELECTOR).forEach(cell => {
|
||||
cell.addEventListener('click', () => {
|
||||
if (cell.classList.contains('disabled')) return;
|
||||
close();
|
||||
@@ -370,22 +573,25 @@ export function showTypePicker({ title, items, onPick, filterTabs, onFilterChang
|
||||
const newItems = onFilterChange(key);
|
||||
grid.innerHTML = buildCells(newItems);
|
||||
bindCellClicks();
|
||||
refreshColumns();
|
||||
setFocused(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Filter logic
|
||||
if (showFilter) {
|
||||
const input = overlay.querySelector('.type-picker-filter') as HTMLInputElement;
|
||||
input.addEventListener('input', () => {
|
||||
const q = input.value.toLowerCase().trim();
|
||||
grid.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
if (filterInput) {
|
||||
filterInput.addEventListener('input', () => {
|
||||
const q = filterInput.value.toLowerCase().trim();
|
||||
grid.querySelectorAll(CELL_SELECTOR).forEach(cell => {
|
||||
const el = cell as HTMLElement;
|
||||
const match = !q || el.dataset.search!.includes(q);
|
||||
el.classList.toggle('disabled', !match);
|
||||
});
|
||||
refreshColumns();
|
||||
setFocused(0);
|
||||
});
|
||||
requestAnimationFrame(() => setTimeout(() => desktopFocus(input), 200));
|
||||
requestAnimationFrame(() => setTimeout(() => desktopFocus(filterInput), 200));
|
||||
}
|
||||
|
||||
// Backdrop click
|
||||
@@ -393,12 +599,41 @@ export function showTypePicker({ title, items, onPick, filterTabs, onFilterChang
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
|
||||
// Escape key
|
||||
// Keyboard navigation
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') close();
|
||||
if (e.key === 'Escape') { close(); return; }
|
||||
|
||||
// Don't hijack arrow keys while the user is editing the filter
|
||||
// input — let the caret move inside the text field normally.
|
||||
if (filterInput && document.activeElement === filterInput
|
||||
&& (e.key === 'ArrowLeft' || e.key === 'ArrowRight'
|
||||
|| e.key === 'Home' || e.key === 'End')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cells = getNavCells();
|
||||
if (cells.length === 0) return;
|
||||
const action = handleGridKey(e.key, focusedIdx, cells.length, cachedColumns);
|
||||
if (!action.handled) return;
|
||||
e.preventDefault();
|
||||
if (action.pick) {
|
||||
// Treat focusedIdx === -1 (rAF race before initial setFocused) as
|
||||
// the first cell, matching the visual cursor seed.
|
||||
const idx = action.nextIndex >= 0 ? action.nextIndex : 0;
|
||||
const cell = cells[idx];
|
||||
if (!cell) return;
|
||||
close();
|
||||
onPick(cell.dataset.value!);
|
||||
return;
|
||||
}
|
||||
setFocused(action.nextIndex);
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
|
||||
// Animate in
|
||||
requestAnimationFrame(() => overlay.classList.add('open'));
|
||||
// Animate in, prime the column cache, and seed keyboard cursor on first cell.
|
||||
requestAnimationFrame(() => {
|
||||
overlay.classList.add('open');
|
||||
refreshColumns();
|
||||
setFocused(0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const _targetTypeIcons = { led: _svg(P.lightbulb), wled: _svg(P.lightbulb
|
||||
const _pictureSourceTypeIcons = { raw: _svg(P.monitor), processed: _svg(P.palette), static_image: _svg(P.image), video: _svg(P.film) };
|
||||
const _colorStripTypeIcons = {
|
||||
picture_advanced: _svg(P.monitor),
|
||||
static: _svg(P.palette), gradient: _svg(P.rainbow),
|
||||
single_color: _svg(P.palette), gradient: _svg(P.rainbow),
|
||||
effect: _svg(P.zap), composite: _svg(P.link),
|
||||
mapped: _svg(P.mapPin), mapped_zones: _svg(P.mapPin),
|
||||
audio: _svg(P.music), audio_visualization: _svg(P.music),
|
||||
@@ -42,13 +42,16 @@ const _valueSourceTypeIcons = {
|
||||
css_extract: _svg(P.droplets),
|
||||
system_metrics: _svg(P.cpu),
|
||||
game_event: _svg(P.gamepad2),
|
||||
http: _svg(P.globe),
|
||||
};
|
||||
const _audioSourceTypeIcons = { capture: _svg(P.volume2), processed: _svg(P.slidersHorizontal) };
|
||||
const _deviceTypeIcons = {
|
||||
wled: _svg(P.wifi), adalight: _svg(P.usb), ambiled: _svg(P.usb),
|
||||
mqtt: _svg(P.send), ws: _svg(P.globe), openrgb: _svg(P.palette),
|
||||
dmx: _svg(P.radio), mock: _svg(P.wrench),
|
||||
espnow: _svg(P.radio), hue: _svg(P.lightbulb), usbhid: _svg(P.usb),
|
||||
dmx: _svg(P.radio), ddp: _svg(P.send), opc: _svg(P.send), mock: _svg(P.wrench),
|
||||
espnow: _svg(P.radio), hue: _svg(P.lightbulb), yeelight: _svg(P.lightbulb), wiz: _svg(P.lightbulb), lifx: _svg(P.lightbulb), govee: _svg(P.lightbulb),
|
||||
nanoleaf: _svg(P.hexagon),
|
||||
usbhid: _svg(P.usb),
|
||||
spi: _svg(P.plug), chroma: _svg(P.zap), gamesense: _svg(P.target),
|
||||
ble: _svg(P.bluetooth),
|
||||
group: _svg(P.layers),
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Shared helpers for "light target" editors (HA Light + Z2M Light).
|
||||
*
|
||||
* Both editors expose the same five tunable knobs (brightness, update_rate,
|
||||
* transition, color_tolerance, min_brightness_threshold), the same unified
|
||||
* Color Source picker (CSS sources + colour-returning value sources), and
|
||||
* the same IconSelect-driven stop_action picker. This module hosts the
|
||||
* boilerplate for those three concerns so the per-protocol editor files
|
||||
* stay focused on what genuinely differs (URLs, mapping row chrome,
|
||||
* binding-target picker, card chips).
|
||||
*/
|
||||
|
||||
import { BindableScalarWidget } from './bindable-scalar.ts';
|
||||
import { IconSelect, type IconSelectItem } from './icon-select.ts';
|
||||
import { getColorStripIcon, getValueSourceIcon } from './icons.ts';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Bindable scalar widget bundle
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface WidgetConfig {
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
default: number;
|
||||
format: (v: number) => string;
|
||||
}
|
||||
|
||||
const DEFAULT_WIDGET_CONFIG: Record<WidgetKey, WidgetConfig> = {
|
||||
brightness: { min: 0, max: 1, step: 0.05, default: 1.0, format: (v) => v.toFixed(2) },
|
||||
updateRate: { min: 0.5, max: 5.0, step: 0.1, default: 2.0, format: (v) => v.toFixed(1) },
|
||||
transition: { min: 0.0, max: 10, step: 0.1, default: 0.5, format: (v) => v.toFixed(1) },
|
||||
colorTolerance: { min: 0, max: 50, step: 1, default: 5, format: (v) => String(Math.round(v)) },
|
||||
minBrightnessThreshold: { min: 0, max: 254, step: 1, default: 0, format: (v) => String(Math.round(v)) },
|
||||
};
|
||||
|
||||
export type WidgetKey =
|
||||
| 'brightness'
|
||||
| 'updateRate'
|
||||
| 'transition'
|
||||
| 'colorTolerance'
|
||||
| 'minBrightnessThreshold';
|
||||
|
||||
/** Maps a WidgetKey to its `id`/`container` slug. */
|
||||
const WIDGET_SLUG: Record<WidgetKey, string> = {
|
||||
brightness: 'brightness',
|
||||
updateRate: 'update-rate',
|
||||
transition: 'transition',
|
||||
colorTolerance: 'color-tolerance',
|
||||
minBrightnessThreshold: 'min-brightness-threshold',
|
||||
};
|
||||
|
||||
export interface LightTargetWidgetsConfig {
|
||||
/** Prefix for both the BindableScalarWidget `idPrefix` and the container
|
||||
* element id (e.g. `'ha-light-editor'` → container `'ha-light-editor-brightness-container'`). */
|
||||
idPrefix: string;
|
||||
/** Value-source list provider for the binding mode of every widget. */
|
||||
valueSources: () => Array<{ id: string; name: string; source_type: string }>;
|
||||
/** Per-widget overrides (e.g. wider update_rate range for Z2M). Each
|
||||
* field is `Partial<WidgetConfig>` and merges over the defaults. */
|
||||
overrides?: Partial<Record<WidgetKey, Partial<WidgetConfig>>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy bundle of the five BindableScalarWidgets every light-target editor
|
||||
* uses. Widgets are created on first `ensure(key)` and cached; `destroyAll()`
|
||||
* tears them down and resets the cache so the editor can be re-opened cleanly.
|
||||
*/
|
||||
export class LightTargetWidgets {
|
||||
private _widgets: Partial<Record<WidgetKey, BindableScalarWidget>> = {};
|
||||
|
||||
constructor(private opts: LightTargetWidgetsConfig) {}
|
||||
|
||||
/** Lazy-create (or return cached) widget for the given key. */
|
||||
ensure(key: WidgetKey): BindableScalarWidget {
|
||||
let w = this._widgets[key];
|
||||
if (w) return w;
|
||||
|
||||
const cfg = { ...DEFAULT_WIDGET_CONFIG[key], ...(this.opts.overrides?.[key] || {}) };
|
||||
const slug = WIDGET_SLUG[key];
|
||||
const container = document.getElementById(`${this.opts.idPrefix}-${slug}-container`);
|
||||
if (!container) {
|
||||
throw new Error(`LightTargetWidgets: container not found for key '${key}' (expected #${this.opts.idPrefix}-${slug}-container)`);
|
||||
}
|
||||
w = new BindableScalarWidget({
|
||||
container,
|
||||
idPrefix: `${this.opts.idPrefix}-${slug}`,
|
||||
min: cfg.min,
|
||||
max: cfg.max,
|
||||
step: cfg.step,
|
||||
default: cfg.default,
|
||||
format: cfg.format,
|
||||
valueSources: this.opts.valueSources,
|
||||
});
|
||||
this._widgets[key] = w;
|
||||
return w;
|
||||
}
|
||||
|
||||
/** Apply target-response values to all five widgets, lazy-creating each.
|
||||
* Missing fields fall back to the per-key default. */
|
||||
applyValues(data: {
|
||||
brightness?: any;
|
||||
update_rate?: any;
|
||||
transition?: any;
|
||||
color_tolerance?: any;
|
||||
min_brightness_threshold?: any;
|
||||
}): void {
|
||||
this.ensure('brightness').setValue(data.brightness ?? this._defaultFor('brightness'));
|
||||
this.ensure('updateRate').setValue(data.update_rate ?? this._defaultFor('updateRate'));
|
||||
this.ensure('transition').setValue(data.transition ?? this._defaultFor('transition'));
|
||||
this.ensure('colorTolerance').setValue(data.color_tolerance ?? this._defaultFor('colorTolerance'));
|
||||
this.ensure('minBrightnessThreshold').setValue(data.min_brightness_threshold ?? this._defaultFor('minBrightnessThreshold'));
|
||||
}
|
||||
|
||||
/** Reset all widgets to their (override-aware) default values. */
|
||||
applyDefaults(): void {
|
||||
this.ensure('brightness').setValue(this._defaultFor('brightness'));
|
||||
this.ensure('updateRate').setValue(this._defaultFor('updateRate'));
|
||||
this.ensure('transition').setValue(this._defaultFor('transition'));
|
||||
this.ensure('colorTolerance').setValue(this._defaultFor('colorTolerance'));
|
||||
this.ensure('minBrightnessThreshold').setValue(this._defaultFor('minBrightnessThreshold'));
|
||||
}
|
||||
|
||||
/** Read the current value (BindableFloat-shaped) for a widget. Auto-creates if not yet ensured. */
|
||||
getValue(key: WidgetKey) {
|
||||
return this.ensure(key).getValue();
|
||||
}
|
||||
|
||||
destroyAll(): void {
|
||||
for (const key of Object.keys(this._widgets) as WidgetKey[]) {
|
||||
this._widgets[key]?.destroy();
|
||||
}
|
||||
this._widgets = {};
|
||||
}
|
||||
|
||||
private _defaultFor(key: WidgetKey): number {
|
||||
return this.opts.overrides?.[key]?.default ?? DEFAULT_WIDGET_CONFIG[key].default;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Unified Color Source picker (CSS + colour value sources)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ColorSourcePickerLabels {
|
||||
/** Header for the CSS sources section (e.g. "Color strip"). */
|
||||
cssHeader: string;
|
||||
/** Header for the colour value sources section (e.g. "Color value source"). */
|
||||
colorVsHeader: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the dual-section item list for the unified Color Source picker.
|
||||
* Items use the `css:<id>` / `cvs:<id>` value encoding that both editors
|
||||
* decode in their `onChange` handlers.
|
||||
*/
|
||||
export function buildColorSourcePickerItems(
|
||||
cssSources: Array<{ id: string; name: string; source_type: string }>,
|
||||
colorValueSources: Array<{ id: string; name: string; source_type: string }>,
|
||||
labels: ColorSourcePickerLabels,
|
||||
): Array<{ value: string; label: string; icon: string; desc?: string; header?: boolean }> {
|
||||
const items: Array<{ value: string; label: string; icon: string; desc?: string; header?: boolean }> = [];
|
||||
|
||||
if (cssSources.length > 0) {
|
||||
items.push({ value: '', label: labels.cssHeader, icon: '', header: true });
|
||||
for (const s of cssSources) {
|
||||
items.push({
|
||||
value: `css:${s.id}`,
|
||||
label: s.name,
|
||||
icon: getColorStripIcon(s.source_type),
|
||||
desc: s.source_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (colorValueSources.length > 0) {
|
||||
items.push({ value: '', label: labels.colorVsHeader, icon: '', header: true });
|
||||
for (const s of colorValueSources) {
|
||||
items.push({
|
||||
value: `cvs:${s.id}`,
|
||||
label: s.name,
|
||||
icon: getValueSourceIcon(s.source_type),
|
||||
desc: s.source_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Decode a unified picker value (`css:abc` / `cvs:abc` / `''`) into kind+id. */
|
||||
export function decodeColorSourceValue(raw: string): { kind: 'css' | 'color_vs'; id: string } {
|
||||
const kind: 'css' | 'color_vs' = raw.startsWith('cvs:') ? 'color_vs' : 'css';
|
||||
const id = raw.includes(':') ? raw.slice(raw.indexOf(':') + 1) : '';
|
||||
return { kind, id };
|
||||
}
|
||||
|
||||
/** Encode a kind+id pair into the unified picker's option-value string. */
|
||||
export function encodeColorSourceValue(kind: 'css' | 'color_vs', id: string): string {
|
||||
if (!id) return '';
|
||||
return `${kind === 'color_vs' ? 'cvs' : 'css'}:${id}`;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Stop-action IconSelect
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wire (or refresh) an IconSelect for a stop-action `<select>`.
|
||||
*
|
||||
* Pass the existing `IconSelect | null` ref; the helper either calls
|
||||
* `updateItems()` on it or constructs a new one. The (new or existing)
|
||||
* instance is returned so the caller can keep the singleton ref alive.
|
||||
*/
|
||||
export function ensureStopActionIconSelect(
|
||||
selectId: string,
|
||||
items: IconSelectItem[],
|
||||
columns: number,
|
||||
existing: IconSelect | null,
|
||||
): IconSelect | null {
|
||||
const sel = document.getElementById(selectId) as HTMLSelectElement | null;
|
||||
if (!sel) return existing;
|
||||
if (existing) {
|
||||
existing.updateItems(items);
|
||||
return existing;
|
||||
}
|
||||
return new IconSelect({ target: sel, items, columns });
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* MiniSelect — compact, icon-less dropdown that replaces plain ``<select>``.
|
||||
*
|
||||
* IconSelect requires an SVG icon per option, which doesn't fit the
|
||||
* dashboard's inline perf-cell controls (mode / window / yScale). Plain
|
||||
* ``<select>`` is banned project-wide because it breaks the UI's visual
|
||||
* consistency. MiniSelect fills the gap: a styled trigger button that
|
||||
* shows the current option label, plus a small popup with the option
|
||||
* labels. The original ``<select>`` is hidden but kept in the DOM and
|
||||
* receives a native ``change`` event whenever the user picks an option,
|
||||
* so existing handlers keep working with no changes.
|
||||
*
|
||||
* Usage:
|
||||
* const sel = document.getElementById('perf-mode') as HTMLSelectElement;
|
||||
* new MiniSelect(sel);
|
||||
*
|
||||
* The trigger displays each option's visible text from its ``<option>``
|
||||
* label; option values come from the underlying ``<select>``.
|
||||
*/
|
||||
|
||||
import { closeAllIconSelects } from './icon-select.ts';
|
||||
import { escapeHtml } from './api.ts';
|
||||
import { desktopFocus } from './ui.ts';
|
||||
|
||||
const POPUP_CLASS = 'mini-select-popup';
|
||||
const FOCUSED_CLASS = 'focused';
|
||||
const CELL_SELECTOR = '.mini-select-option';
|
||||
const FOCUSED_SELECTOR = `${CELL_SELECTOR}.${FOCUSED_CLASS}`;
|
||||
|
||||
const _registry: Set<MiniSelect> = new Set();
|
||||
|
||||
/** Close every open MiniSelect popup. */
|
||||
export function closeAllMiniSelects(): void {
|
||||
for (const ms of _registry) ms._close();
|
||||
}
|
||||
|
||||
let _globalListenerAdded = false;
|
||||
function _ensureGlobalListener(): void {
|
||||
if (_globalListenerAdded) return;
|
||||
_globalListenerAdded = true;
|
||||
document.addEventListener('click', (e) => {
|
||||
const t = e.target as HTMLElement;
|
||||
if (!t.closest(`.${POPUP_CLASS}`) && !t.closest('.mini-select-trigger')) {
|
||||
closeAllMiniSelects();
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeAllMiniSelects();
|
||||
});
|
||||
}
|
||||
|
||||
interface MiniSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export class MiniSelect {
|
||||
_select: HTMLSelectElement;
|
||||
_options: MiniSelectOption[];
|
||||
_trigger: HTMLButtonElement;
|
||||
_popup: HTMLDivElement;
|
||||
_focusedIndex = -1;
|
||||
|
||||
constructor(target: HTMLSelectElement) {
|
||||
// Picking up plain ``<select>``s in the same modal as IconSelects is
|
||||
// intentional — we want the same click-away/Escape behaviour, so we
|
||||
// also close any open IconSelect popup when ours opens.
|
||||
_ensureGlobalListener();
|
||||
|
||||
this._select = target;
|
||||
this._options = Array.from(target.options).map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.textContent || opt.value,
|
||||
}));
|
||||
|
||||
target.style.display = 'none';
|
||||
|
||||
this._trigger = document.createElement('button');
|
||||
this._trigger.type = 'button';
|
||||
this._trigger.className = 'mini-select-trigger';
|
||||
this._trigger.setAttribute('aria-haspopup', 'listbox');
|
||||
this._trigger.setAttribute('aria-expanded', 'false');
|
||||
if (target.title) this._trigger.title = target.title;
|
||||
this._trigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._toggle();
|
||||
});
|
||||
target.parentNode!.insertBefore(this._trigger, target.nextSibling);
|
||||
|
||||
this._popup = document.createElement('div');
|
||||
this._popup.className = POPUP_CLASS;
|
||||
this._popup.tabIndex = -1;
|
||||
this._popup.setAttribute('role', 'listbox');
|
||||
this._popup.addEventListener('click', (e) => e.stopPropagation());
|
||||
this._popup.addEventListener('keydown', (e) => this._onKey(e));
|
||||
this._popup.innerHTML = this._buildPopup();
|
||||
document.body.appendChild(this._popup);
|
||||
|
||||
this._bindOptionClicks();
|
||||
this._syncTrigger();
|
||||
_registry.add(this);
|
||||
}
|
||||
|
||||
/** Refresh trigger label + popup content after an external change. */
|
||||
refresh(): void {
|
||||
// Rebuild option list in case the underlying <select> changed.
|
||||
this._options = Array.from(this._select.options).map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.textContent || opt.value,
|
||||
}));
|
||||
this._popup.innerHTML = this._buildPopup();
|
||||
this._bindOptionClicks();
|
||||
this._syncTrigger();
|
||||
}
|
||||
|
||||
/** Remove the enhancement and restore the native <select>. */
|
||||
destroy(): void {
|
||||
_registry.delete(this);
|
||||
this._trigger.remove();
|
||||
this._popup.remove();
|
||||
this._select.style.display = '';
|
||||
}
|
||||
|
||||
// ── Internals ─────────────────────────────────────────────────
|
||||
|
||||
_buildPopup(): string {
|
||||
return this._options
|
||||
.map(
|
||||
(o, i) =>
|
||||
`<div class="mini-select-option" role="option" data-value="${escapeHtml(o.value)}" data-index="${i}">${escapeHtml(o.label)}</div>`,
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
_bindOptionClicks(): void {
|
||||
this._popup.querySelectorAll<HTMLElement>(CELL_SELECTOR).forEach((cell) => {
|
||||
cell.addEventListener('click', () => {
|
||||
this._pick(cell.dataset.value || '');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_syncTrigger(): void {
|
||||
const cur = this._options.find((o) => o.value === this._select.value);
|
||||
const label = cur ? cur.label : this._options[0]?.label || '';
|
||||
this._trigger.innerHTML = `<span class="mini-select-trigger-label">${escapeHtml(label)}</span><span class="mini-select-trigger-arrow">▾</span>`;
|
||||
this._popup.querySelectorAll<HTMLElement>(CELL_SELECTOR).forEach((cell) => {
|
||||
const active = cell.dataset.value === this._select.value;
|
||||
cell.classList.toggle('active', active);
|
||||
cell.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
_position(): void {
|
||||
const rect = this._trigger.getBoundingClientRect();
|
||||
const pad = 8;
|
||||
const gap = 4;
|
||||
const popupW = Math.max(rect.width, 120);
|
||||
const spaceBelow = window.innerHeight - rect.bottom - gap - pad;
|
||||
const spaceAbove = rect.top - gap - pad;
|
||||
const openUp = spaceBelow < 160 && spaceAbove > spaceBelow;
|
||||
const available = openUp ? spaceAbove : spaceBelow;
|
||||
|
||||
let left = rect.left;
|
||||
if (left + popupW > window.innerWidth - pad) {
|
||||
left = window.innerWidth - pad - popupW;
|
||||
}
|
||||
if (left < pad) left = pad;
|
||||
this._popup.style.left = `${left}px`;
|
||||
this._popup.style.width = `${popupW}px`;
|
||||
this._popup.style.maxHeight = `${available}px`;
|
||||
if (openUp) {
|
||||
this._popup.style.top = '';
|
||||
this._popup.style.bottom = `${window.innerHeight - rect.top + gap}px`;
|
||||
} else {
|
||||
this._popup.style.top = `${rect.bottom + gap}px`;
|
||||
this._popup.style.bottom = '';
|
||||
}
|
||||
}
|
||||
|
||||
_toggle(): void {
|
||||
const isOpen = this._popup.classList.contains('open');
|
||||
closeAllIconSelects();
|
||||
closeAllMiniSelects();
|
||||
if (isOpen) return;
|
||||
this._position();
|
||||
this._popup.classList.add('open');
|
||||
this._trigger.setAttribute('aria-expanded', 'true');
|
||||
requestAnimationFrame(() => desktopFocus(this._popup));
|
||||
const activeIdx = this._options.findIndex((o) => o.value === this._select.value);
|
||||
this._setFocused(activeIdx >= 0 ? activeIdx : 0);
|
||||
}
|
||||
|
||||
_close(): void {
|
||||
if (!this._popup.classList.contains('open')) return;
|
||||
this._popup.classList.remove('open');
|
||||
this._trigger.setAttribute('aria-expanded', 'false');
|
||||
this._clearFocused();
|
||||
}
|
||||
|
||||
_clearFocused(): void {
|
||||
this._popup.querySelectorAll(FOCUSED_SELECTOR).forEach((c) => c.classList.remove(FOCUSED_CLASS));
|
||||
this._focusedIndex = -1;
|
||||
}
|
||||
|
||||
_setFocused(idx: number): void {
|
||||
const cells = Array.from(this._popup.querySelectorAll<HTMLElement>(CELL_SELECTOR));
|
||||
if (cells.length === 0) return;
|
||||
const clamped = Math.max(0, Math.min(idx, cells.length - 1));
|
||||
this._clearFocused();
|
||||
cells[clamped].classList.add(FOCUSED_CLASS);
|
||||
cells[clamped].scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
this._focusedIndex = clamped;
|
||||
}
|
||||
|
||||
_onKey(e: KeyboardEvent): void {
|
||||
if (!this._popup.classList.contains('open')) return;
|
||||
const total = this._options.length;
|
||||
const cur = this._focusedIndex >= 0 ? this._focusedIndex : 0;
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
this._setFocused(Math.min(cur + 1, total - 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
this._setFocused(Math.max(cur - 1, 0));
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
this._setFocused(0);
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
this._setFocused(total - 1);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (this._focusedIndex >= 0) {
|
||||
this._pick(this._options[this._focusedIndex].value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_pick(value: string): void {
|
||||
this._select.value = value;
|
||||
this._syncTrigger();
|
||||
// Dispatch the native event so existing handlers attached to the
|
||||
// underlying <select> keep working without modification.
|
||||
this._select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
this._close();
|
||||
desktopFocus(this._trigger);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance every plain ``<select>`` matching *selector* under *root* into a
|
||||
* MiniSelect. Selects that are already enhanced by another component
|
||||
* (``IconSelect`` / ``EntitySelect`` — both hide the target via
|
||||
* ``style.display = 'none'``) are skipped so the two wrappers don't compete
|
||||
* for the same `<select>`.
|
||||
*/
|
||||
export function enhanceMiniSelects(root: ParentNode, selector = 'select'): MiniSelect[] {
|
||||
const out: MiniSelect[] = [];
|
||||
root.querySelectorAll<HTMLSelectElement>(selector).forEach((sel) => {
|
||||
if (sel.dataset.miniEnhanced === '1') return;
|
||||
// Skip selects already hidden by an upstream IconSelect/EntitySelect.
|
||||
if (sel.style.display === 'none') return;
|
||||
sel.dataset.miniEnhanced = '1';
|
||||
out.push(new MiniSelect(sel));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user