Compare commits
22 Commits
| 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 |
@@ -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,170 @@
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +29,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
_PREVIEW_ALLOWED_TYPES = {
|
||||
"static",
|
||||
"single_color",
|
||||
"gradient",
|
||||
"effect",
|
||||
"daylight",
|
||||
@@ -97,65 +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*.
|
||||
|
||||
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
|
||||
|
||||
if source.source_type == "audio":
|
||||
from ledgrab.api.dependencies import (
|
||||
get_audio_processing_template_store,
|
||||
get_audio_source_store,
|
||||
get_audio_template_store,
|
||||
)
|
||||
from ledgrab.core.processing.audio_stream import AudioColorStripStream
|
||||
# The game-event bus is optional in preview contexts.
|
||||
try:
|
||||
from ledgrab.api.dependencies import get_game_event_bus
|
||||
|
||||
s = AudioColorStripStream(
|
||||
source,
|
||||
mgr.audio_capture_manager,
|
||||
get_audio_source_store(),
|
||||
get_audio_template_store(),
|
||||
get_audio_processing_template_store(),
|
||||
)
|
||||
elif source.source_type == "weather":
|
||||
from ledgrab.core.processing.weather_stream import WeatherColorStripStream
|
||||
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
|
||||
|
||||
s = WeatherColorStripStream(source, mgr.weather_manager)
|
||||
elif source.source_type == "game_event":
|
||||
from ledgrab.core.processing.game_event_stream import GameEventColorStripStream
|
||||
|
||||
s = GameEventColorStripStream(source)
|
||||
try:
|
||||
from ledgrab.api.dependencies import get_game_event_bus
|
||||
|
||||
bus = get_game_event_bus()
|
||||
except RuntimeError as e:
|
||||
logger.debug("Preview: no game event bus available: %s", e)
|
||||
else:
|
||||
if bus is not None:
|
||||
s.set_event_bus(bus)
|
||||
elif source.source_type == "mapped":
|
||||
from ledgrab.core.processing.mapped_stream import MappedColorStripStream
|
||||
|
||||
s = MappedColorStripStream(source, csm)
|
||||
elif source.source_type == "composite":
|
||||
from ledgrab.api.dependencies import get_cspt_store
|
||||
from ledgrab.core.processing.composite_stream import CompositeColorStripStream
|
||||
|
||||
s = CompositeColorStripStream(
|
||||
source, csm, mgr.value_stream_manager, get_cspt_store(), depth=0
|
||||
)
|
||||
elif source.source_type == "processed":
|
||||
from ledgrab.api.dependencies import get_cspt_store
|
||||
from ledgrab.core.processing.processed_stream import ProcessedColorStripStream
|
||||
|
||||
s = ProcessedColorStripStream(source, csm, get_cspt_store())
|
||||
else:
|
||||
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)
|
||||
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:
|
||||
@@ -428,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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -175,26 +175,57 @@ 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)
|
||||
elif isinstance(target, Z2MLightOutputTarget):
|
||||
return _z2m_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 =====
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -38,12 +87,16 @@ class AutomationEngine:
|
||||
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_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
|
||||
@@ -65,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")
|
||||
|
||||
@@ -94,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")
|
||||
|
||||
@@ -183,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:
|
||||
@@ -198,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()
|
||||
|
||||
@@ -337,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,
|
||||
@@ -347,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:
|
||||
@@ -436,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,
|
||||
@@ -636,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,107 +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.
|
||||
# float32 is sufficient: max cumsum value is edge_len * 255 (≈2M @ 8K
|
||||
# screens) which fits exactly in float32's 24-bit mantissa. Halves
|
||||
# memory bandwidth on the hot reduction.
|
||||
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.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)
|
||||
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.
|
||||
@@ -669,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.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)
|
||||
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,
|
||||
@@ -734,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:
|
||||
@@ -326,7 +332,11 @@ def calculate_dominant_color(pixels: np.ndarray) -> tuple[int, int, int]:
|
||||
|
||||
max_samples = 1000
|
||||
if n > max_samples:
|
||||
indices = np.random.randint(0, 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]
|
||||
|
||||
# Quantize to 32 levels/channel (drop low 3 bits) and pack into uint32:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
+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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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.
|
||||
@@ -46,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 ──────────────────────────────
|
||||
@@ -229,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)
|
||||
@@ -160,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
|
||||
@@ -74,6 +75,7 @@ class ProcessorDependencies:
|
||||
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
|
||||
@@ -169,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
|
||||
|
||||
@@ -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:
|
||||
@@ -185,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
|
||||
|
||||
@@ -224,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:
|
||||
@@ -233,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:
|
||||
|
||||
@@ -14,6 +14,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.z2m_light_output_target import (
|
||||
DEFAULT_Z2M_BASE_TOPIC,
|
||||
@@ -270,45 +271,12 @@ class Z2MLightTargetProcessor(TargetProcessor):
|
||||
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:
|
||||
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
|
||||
"""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()
|
||||
|
||||
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"Z2M light {self._target_id}: failed to acquire color VS: {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 re-acquire CSS stream: {e}"
|
||||
)
|
||||
|
||||
# ─────────── WebSocket clients ───────────
|
||||
|
||||
def add_ws_client(self, ws: Any) -> None:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -5982,3 +5982,312 @@ body.composite-layer-dragging .composite-layer-drag-handle {
|
||||
.icon-picker-toolbar { flex-direction: column; align-items: stretch; }
|
||||
}
|
||||
|
||||
/* ── HTTP endpoint editor: custom headers list ─────────────────
|
||||
Mirrors the .group-child-row vocabulary used by device-groups so
|
||||
the modal feels native to the rest of the app. Each row is a
|
||||
bordered card on `--bg-color`, with two input slots and a trash
|
||||
button on the right; the leading numeric index gives the rows a
|
||||
sense of order and matches the rack-panel section numbering. */
|
||||
.http-headers-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.http-headers-empty {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.http-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px 6px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background: var(--bg-color);
|
||||
transition: border-color 0.2s, background 0.15s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.http-header-row:hover,
|
||||
.http-header-row:focus-within {
|
||||
border-color: color-mix(in srgb, var(--primary-color) 40%, var(--border-color));
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.http-header-index {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary);
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
opacity: 0.6;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.http-header-fields {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.http-header-name,
|
||||
.http-header-value {
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
font-size: 0.8125rem;
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
}
|
||||
|
||||
.http-header-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.http-header-name:focus,
|
||||
.http-header-value:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color) 25%, transparent);
|
||||
}
|
||||
|
||||
.http-header-remove {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.http-header-row:hover .http-header-remove,
|
||||
.http-header-row:focus-within .http-header-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.http-header-remove:hover {
|
||||
color: var(--danger-color);
|
||||
border-color: color-mix(in srgb, var(--danger-color) 40%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--danger-color) 10%, transparent);
|
||||
}
|
||||
|
||||
.btn-add-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8125rem;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.btn-add-header-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.http-header-fields {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.http-header-index {
|
||||
align-self: flex-start;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.http-header-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.http-header-remove {
|
||||
align-self: flex-start;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── HTTP endpoint editor: inline test request UI ─────────────
|
||||
The Test button sits inside the request section and renders its
|
||||
response below as a result card. Status badges use the success /
|
||||
danger tokens to stay consistent with toast colors. */
|
||||
.http-endpoint-test-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.http-endpoint-test-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.http-endpoint-test-btn .http-endpoint-test-btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.http-endpoint-test-btn .http-endpoint-test-btn-icon .icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.http-endpoint-test-btn.loading {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.http-test-output {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.http-test-pending {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.http-test-pending-spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid color-mix(in srgb, var(--primary-color) 25%, transparent);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: http-test-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes http-test-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.http-test-result {
|
||||
border: 1px solid var(--border-color);
|
||||
border-left-width: 3px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-color);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.http-test-result.http-test-ok {
|
||||
border-left-color: var(--success-color, #28a745);
|
||||
background: color-mix(in srgb, var(--success-color, #28a745) 6%, var(--bg-color));
|
||||
}
|
||||
|
||||
.http-test-result.http-test-fail {
|
||||
border-left-color: var(--danger-color, #f44336);
|
||||
background: color-mix(in srgb, var(--danger-color, #f44336) 6%, var(--bg-color));
|
||||
}
|
||||
|
||||
.http-test-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.http-test-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.http-test-badge .icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.http-test-badge-ok {
|
||||
background: color-mix(in srgb, var(--success-color, #28a745) 18%, transparent);
|
||||
color: var(--success-color, #28a745);
|
||||
}
|
||||
|
||||
.http-test-badge-fail {
|
||||
background: color-mix(in srgb, var(--danger-color, #f44336) 18%, transparent);
|
||||
color: var(--danger-color, #f44336);
|
||||
}
|
||||
|
||||
.http-test-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.http-test-error {
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--danger-color, #f44336) 10%, var(--card-bg));
|
||||
color: var(--text-color);
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.http-test-body-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.http-test-body {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-color);
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,6 +42,7 @@ 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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -156,6 +156,24 @@ export class Modal {
|
||||
return Object.keys(this._initialValues).some(k => this._initialValues[k] !== cur[k]);
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op save guard for edit modals. When `entityId` is truthy (we are
|
||||
* editing an existing entity), snapshot tracking is configured, and no
|
||||
* tracked field has changed, force-close the modal silently and return
|
||||
* `true` so the caller can early-return — skipping the network request
|
||||
* and the misleading "updated" toast.
|
||||
*
|
||||
* Returns `false` when the save flow must continue (create flow, no
|
||||
* snapshot taken, or at least one tracked field changed).
|
||||
*/
|
||||
closeIfPristine(entityId: unknown): boolean {
|
||||
if (!entityId) return false;
|
||||
if (Object.keys(this._initialValues).length === 0) return false;
|
||||
if (this.isDirty()) return false;
|
||||
this.forceClose();
|
||||
return true;
|
||||
}
|
||||
|
||||
showError(msg: string) {
|
||||
if (this.errorEl) {
|
||||
this.errorEl.textContent = msg;
|
||||
|
||||
@@ -16,7 +16,7 @@ import { DataCache } from './cache.ts';
|
||||
import type {
|
||||
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
||||
ValueSource, AudioSource, PictureSource, ScenePreset,
|
||||
SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, Asset, Automation, Display, FilterDef, EngineInfo,
|
||||
SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, HTTPEndpoint, Asset, Automation, Display, FilterDef, EngineInfo,
|
||||
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
||||
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
||||
GameIntegration, GameAdapterInfo,
|
||||
@@ -371,6 +371,14 @@ export const mqttSourcesCache = new DataCache<MQTTSource[]>({
|
||||
});
|
||||
mqttSourcesCache.subscribe(v => { _cachedMQTTSources = v; });
|
||||
|
||||
export let _cachedHTTPEndpoints: HTTPEndpoint[] = [];
|
||||
|
||||
export const httpEndpointsCache = new DataCache<HTTPEndpoint[]>({
|
||||
endpoint: '/http/endpoints',
|
||||
extractData: json => json.endpoints || [],
|
||||
});
|
||||
httpEndpointsCache.subscribe(v => { _cachedHTTPEndpoints = v; });
|
||||
|
||||
export const assetsCache = new DataCache<Asset[]>({
|
||||
endpoint: '/assets',
|
||||
extractData: json => json.assets || [],
|
||||
|
||||
@@ -43,6 +43,23 @@ export function writeJson(key: string, value: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSON string, returning ``fallback`` on any parse failure.
|
||||
*
|
||||
* Use for hot-path string-to-JSON conversions where a malformed payload
|
||||
* (corrupt WebSocket frame, stale ``data-*`` attribute, hand-edited
|
||||
* mapping in storage) would otherwise raise an uncaught exception.
|
||||
*/
|
||||
export function safeJsonParse<T = unknown>(raw: string | null | undefined, fallback: T): T {
|
||||
if (raw == null || raw === '') return fallback;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch (err) {
|
||||
logError('safeJsonParse', err);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Common type guards ────────────────────────────────────────
|
||||
|
||||
export function isObject(v: unknown): v is Record<string, unknown> {
|
||||
|
||||
@@ -199,6 +199,8 @@ export async function saveAdvancedCalibration(): Promise<void> {
|
||||
const cssId = _state.cssId;
|
||||
if (!cssId) return;
|
||||
|
||||
if (_modal.closeIfPristine(cssId)) return;
|
||||
|
||||
if (_state.lines.length === 0) {
|
||||
showToast(t('calibration.advanced.no_lines_warning') || 'Add at least one line', 'error');
|
||||
return;
|
||||
|
||||
@@ -380,6 +380,8 @@ export async function showAssetEditor(editId: string): Promise<void> {
|
||||
|
||||
export async function saveAssetMetadata(): Promise<void> {
|
||||
const id = (document.getElementById('asset-editor-id') as HTMLInputElement).value;
|
||||
if (assetEditorModal.closeIfPristine(id)) return;
|
||||
|
||||
const name = (document.getElementById('asset-editor-name') as HTMLInputElement).value.trim();
|
||||
const description = (document.getElementById('asset-editor-description') as HTMLInputElement).value.trim();
|
||||
const errorEl = document.getElementById('asset-editor-error')!;
|
||||
|
||||
@@ -194,6 +194,8 @@ export async function editAudioProcessingTemplate(templateId: string) {
|
||||
|
||||
export async function saveAudioProcessingTemplate() {
|
||||
const templateId = (document.getElementById('apt-id') as HTMLInputElement).value;
|
||||
if (aptModal.closeIfPristine(templateId)) return;
|
||||
|
||||
const name = (document.getElementById('apt-name') as HTMLInputElement).value.trim();
|
||||
const description = (document.getElementById('apt-description') as HTMLInputElement).value.trim();
|
||||
|
||||
|
||||
@@ -153,6 +153,8 @@ export function onAudioSourceTypeChange() {
|
||||
|
||||
export async function saveAudioSource() {
|
||||
const id = (document.getElementById('audio-source-id') as HTMLInputElement).value;
|
||||
if (audioSourceModal.closeIfPristine(id)) return;
|
||||
|
||||
const name = (document.getElementById('audio-source-name') as HTMLInputElement).value.trim();
|
||||
const sourceType = (document.getElementById('audio-source-type') as HTMLSelectElement).value;
|
||||
const description = (document.getElementById('audio-source-description') as HTMLInputElement).value.trim() || null;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import {
|
||||
apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj,
|
||||
scenePresetsCache, _cachedHASources, haSourcesCache,
|
||||
_cachedValueSources, valueSourcesCache,
|
||||
getHAEntityFriendlyName, setHAEntityNames,
|
||||
} from '../core/state.ts';
|
||||
import { prefetchHAEntities } from './home-assistant-sources.ts';
|
||||
@@ -26,6 +27,7 @@ import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts'
|
||||
import { getBaseOrigin } from './settings.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { enhanceMiniSelects } from '../core/mini-select.ts';
|
||||
import { attachProcessPicker } from '../core/process-picker.ts';
|
||||
import { TreeNav } from '../core/tree-nav.ts';
|
||||
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
|
||||
@@ -106,6 +108,12 @@ class AutomationEditorModal extends Modal {
|
||||
|
||||
onForceClose() {
|
||||
if (_automationTagsInput) { _automationTagsInput.destroy(); _automationTagsInput = null; }
|
||||
// Tear down any per-rule portal widgets (http_poll EntitySelect /
|
||||
// IconSelect) the rule rows attached. Walks every rule row in the
|
||||
// modal, no-op for rows that didn't stash widgets.
|
||||
document
|
||||
.querySelectorAll<HTMLElement>('#automation-rules-list .rule-fields-container')
|
||||
.forEach(c => _disposeHTTPPollWidgets(c));
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
@@ -226,6 +234,7 @@ export async function loadAutomations() {
|
||||
automationsCacheObj.fetch(),
|
||||
scenePresetsCache.fetch(),
|
||||
haSourcesCache.fetch(),
|
||||
valueSourcesCache.fetch(),
|
||||
]);
|
||||
|
||||
const sceneMap = new Map(scenes.map(s => [s.id, s]));
|
||||
@@ -345,8 +354,67 @@ const RULE_CHIP_RENDERERS: Record<string, RuleChipBuilder> = {
|
||||
title: tooltip,
|
||||
};
|
||||
},
|
||||
http_poll: (c) => {
|
||||
const vsId = c.value_source_id || '';
|
||||
const vs = (_cachedValueSources || []).find(v => v.id === vsId);
|
||||
const vsLabel = vs?.name || (vsId ? vsId : t('automations.rule.http_poll.no_source'));
|
||||
const op = c.operator || 'equals';
|
||||
const opGlyph = _httpOpGlyph(op);
|
||||
const rhs = op === 'exists' ? '' : ` ${c.value ?? ''}`;
|
||||
return {
|
||||
icon: _icon(P.globe),
|
||||
text: `${vsLabel} ${opGlyph}${rhs}`,
|
||||
title: t('automations.rule.http_poll'),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
type HTTPPollOp = 'equals' | 'not_equals' | 'contains' | 'regex' | 'gt' | 'lt' | 'exists';
|
||||
|
||||
const HTTP_OP_KEYS: HTTPPollOp[] = [
|
||||
'equals', 'not_equals', 'contains', 'regex', 'gt', 'lt', 'exists',
|
||||
];
|
||||
|
||||
const _HTTP_OP_GLYPHS: Record<HTTPPollOp, string> = {
|
||||
equals: '=',
|
||||
not_equals: '≠',
|
||||
contains: '∈',
|
||||
regex: '/.../',
|
||||
gt: '>',
|
||||
lt: '<',
|
||||
exists: '?',
|
||||
};
|
||||
|
||||
const _HTTP_OP_ICON_PATHS: Record<HTTPPollOp, string> = {
|
||||
equals: P.check,
|
||||
not_equals: P.circleOff,
|
||||
contains: P.search,
|
||||
regex: P.code,
|
||||
gt: P.chevronUp,
|
||||
lt: P.chevronDown,
|
||||
exists: P.zap,
|
||||
};
|
||||
|
||||
function _httpOpGlyph(op: string): string {
|
||||
return _HTTP_OP_GLYPHS[op as HTTPPollOp] ?? '=';
|
||||
}
|
||||
|
||||
function _httpOpIconPath(op: string): string {
|
||||
return _HTTP_OP_ICON_PATHS[op as HTTPPollOp] ?? P.check;
|
||||
}
|
||||
|
||||
/** Destroy any EntitySelect / IconSelect widgets that the http_poll rule
|
||||
* branch attached to *container*. Safe to call when there are none —
|
||||
* it just clears the stash. Called both before re-rendering the rule's
|
||||
* fields (rule-type change) and before removing the rule row entirely. */
|
||||
function _disposeHTTPPollWidgets(container: HTMLElement): void {
|
||||
const stash = (container as any)._httpPollWidgets;
|
||||
if (!stash) return;
|
||||
try { stash.vsEntitySelect?.destroy?.(); } catch { /* widget already gone */ }
|
||||
try { stash.opIconSelect?.destroy?.(); } catch { /* widget already gone */ }
|
||||
delete (container as any)._httpPollWidgets;
|
||||
}
|
||||
|
||||
/** Render a chain-arrow separator span. `+` between AND-rules,
|
||||
* the localised OR label between OR-rules, and `→` for the
|
||||
* rule-chain → scene-activation transition. */
|
||||
@@ -681,11 +749,12 @@ export function addAutomationRule() {
|
||||
_autoGenerateAutomationName();
|
||||
}
|
||||
|
||||
const RULE_TYPE_KEYS = ['startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant'];
|
||||
const RULE_TYPE_KEYS = ['startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant', 'http_poll'];
|
||||
const RULE_TYPE_ICONS = {
|
||||
startup: P.power, application: P.smartphone,
|
||||
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor,
|
||||
mqtt: P.radio, webhook: P.globe, home_assistant: P.home,
|
||||
http_poll: P.globe,
|
||||
};
|
||||
|
||||
const MATCH_TYPE_KEYS = ['running', 'topmost', 'topmost_fullscreen', 'fullscreen'];
|
||||
@@ -777,7 +846,7 @@ function addAutomationRuleRow(rule: any) {
|
||||
<select class="rule-type-select">
|
||||
${RULE_TYPE_KEYS.map(k => `<option value="${k}" ${ruleType === k ? 'selected' : ''}>${t('automations.rule.' + k)}</option>`).join('')}
|
||||
</select>
|
||||
<button type="button" class="btn-remove-rule" onclick="this.closest('.automation-rule-row').remove(); if(window._autoGenerateAutomationName) window._autoGenerateAutomationName();" title="Remove">${ICON_TRASH}</button>
|
||||
<button type="button" class="btn-remove-rule" title="Remove">${ICON_TRASH}</button>
|
||||
</div>
|
||||
<div class="rule-fields-container" style="display:none"></div>
|
||||
`;
|
||||
@@ -794,14 +863,29 @@ function addAutomationRuleRow(rule: any) {
|
||||
const typeSelect = row.querySelector('.rule-type-select') as HTMLSelectElement;
|
||||
const container = row.querySelector('.rule-fields-container') as HTMLElement;
|
||||
|
||||
// Remove button — dispose any widgets the rule body stashed (portal
|
||||
// overlays would otherwise leak) before pulling the row from the DOM.
|
||||
const removeBtn = row.querySelector('.btn-remove-rule') as HTMLButtonElement;
|
||||
removeBtn.addEventListener('click', () => {
|
||||
_disposeHTTPPollWidgets(container);
|
||||
row.remove();
|
||||
const autoGen = (window as any)._autoGenerateAutomationName;
|
||||
if (typeof autoGen === 'function') autoGen();
|
||||
});
|
||||
|
||||
// Attach IconSelect to the rule type dropdown
|
||||
const ruleIconSelect = new IconSelect({
|
||||
target: typeSelect,
|
||||
items: _buildRuleTypeItems(),
|
||||
columns: 4,
|
||||
columns: 3,
|
||||
} as any);
|
||||
|
||||
function renderFields(type: any, data: any) {
|
||||
// Tear down any widgets the previous renderFields call attached
|
||||
// (EntitySelect/IconSelect portal overlays to document.body, so
|
||||
// a bare ``container.innerHTML = …`` leaves them in the registry).
|
||||
_disposeHTTPPollWidgets(container);
|
||||
|
||||
if (type === 'startup') {
|
||||
container.innerHTML = `<small class="rule-hint-desc">${t('automations.rule.startup.hint')}</small>`;
|
||||
return;
|
||||
@@ -880,6 +964,7 @@ function addAutomationRuleRow(rule: any) {
|
||||
</select>
|
||||
</div>
|
||||
</div>`;
|
||||
enhanceMiniSelects(container, 'select.rule-display-state');
|
||||
return;
|
||||
}
|
||||
if (type === 'mqtt') {
|
||||
@@ -905,6 +990,7 @@ function addAutomationRuleRow(rule: any) {
|
||||
</select>
|
||||
</div>
|
||||
</div>`;
|
||||
enhanceMiniSelects(container, 'select.rule-mqtt-match-mode');
|
||||
return;
|
||||
}
|
||||
if (type === 'home_assistant') {
|
||||
@@ -987,6 +1073,87 @@ function addAutomationRuleRow(rule: any) {
|
||||
|
||||
return;
|
||||
}
|
||||
if (type === 'http_poll') {
|
||||
const vsId = data.value_source_id || '';
|
||||
const operator = data.operator || 'equals';
|
||||
const valueStr = data.value || '';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="rule-fields">
|
||||
<small class="rule-hint-desc">${t('automations.rule.http_poll.hint')}</small>
|
||||
<div class="rule-field">
|
||||
<label>${t('automations.rule.http_poll.value_source')}</label>
|
||||
<select class="rule-http-value-source">
|
||||
<option value="">—</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="rule-field">
|
||||
<label>${t('automations.rule.http_poll.operator')}</label>
|
||||
<select class="rule-http-operator">
|
||||
${HTTP_OP_KEYS.map(k => `<option value="${k}" ${operator === k ? 'selected' : ''}>${t('automations.rule.http_poll.operator.' + k)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="rule-field rule-http-value-field">
|
||||
<label>${t('automations.rule.http_poll.value')}</label>
|
||||
<input type="text" class="rule-http-value" value="${escapeHtml(valueStr)}" placeholder="${escapeHtml(t('automations.rule.http_poll.value.placeholder'))}">
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Pull only HTTP value sources (source_type === 'http')
|
||||
const httpVs = (_cachedValueSources || []).filter((v: any) => v.source_type === 'http');
|
||||
|
||||
// Wire EntitySelect for the value source picker
|
||||
const vsSelect = container.querySelector('.rule-http-value-source') as HTMLSelectElement;
|
||||
// Pre-populate the option so EntitySelect can sync display text.
|
||||
vsSelect.innerHTML = `<option value="">—</option>` +
|
||||
httpVs.map((v: any) => `<option value="${v.id}" ${v.id === vsId ? 'selected' : ''}>${escapeHtml(v.name)}</option>`).join('');
|
||||
vsSelect.value = vsId || '';
|
||||
const vsEntitySelect = new EntitySelect({
|
||||
target: vsSelect,
|
||||
getItems: () => (_cachedValueSources || [])
|
||||
.filter((v: any) => v.source_type === 'http')
|
||||
.map((v: any) => ({
|
||||
value: v.id,
|
||||
label: v.name,
|
||||
icon: _icon(P.globe),
|
||||
desc: v.json_path || t('automations.rule.http_poll.raw_body'),
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
});
|
||||
|
||||
// Wire IconSelect for operator
|
||||
const opSelect = container.querySelector('.rule-http-operator') as HTMLSelectElement;
|
||||
const opItems = HTTP_OP_KEYS.map(k => ({
|
||||
value: k,
|
||||
icon: _icon(_httpOpIconPath(k)),
|
||||
label: t('automations.rule.http_poll.operator.' + k),
|
||||
desc: t('automations.rule.http_poll.operator.' + k + '.desc'),
|
||||
}));
|
||||
const opIconSelect = new IconSelect({
|
||||
target: opSelect,
|
||||
items: opItems,
|
||||
columns: 3,
|
||||
onChange: (newOp: string) => {
|
||||
// Hide the value field when operator is 'exists'
|
||||
const valField = container.querySelector('.rule-http-value-field') as HTMLElement;
|
||||
if (valField) valField.style.display = newOp === 'exists' ? 'none' : '';
|
||||
},
|
||||
});
|
||||
// Sync initial visibility based on the operator we just loaded.
|
||||
const valField = container.querySelector('.rule-http-value-field') as HTMLElement;
|
||||
if (valField) valField.style.display = operator === 'exists' ? 'none' : '';
|
||||
|
||||
// Stash both widgets so they can be destroyed when the row's
|
||||
// rule type changes (renderFields re-entry) or the row is
|
||||
// removed (button.btn-remove-rule onclick — calls
|
||||
// _disposeHTTPPollWidgets via the row's data hook).
|
||||
(container as any)._httpPollWidgets = {
|
||||
vsEntitySelect,
|
||||
opIconSelect,
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
if (type === 'webhook') {
|
||||
if (data.token) {
|
||||
const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token;
|
||||
@@ -1102,6 +1269,18 @@ function getAutomationEditorRules() {
|
||||
state: (row.querySelector('.rule-ha-state') as HTMLInputElement).value,
|
||||
match_mode: (row.querySelector('.rule-ha-match-mode') as HTMLSelectElement).value || 'exact',
|
||||
});
|
||||
} else if (ruleType === 'http_poll') {
|
||||
const op = (row.querySelector('.rule-http-operator') as HTMLSelectElement).value || 'equals';
|
||||
const r: any = {
|
||||
rule_type: 'http_poll',
|
||||
value_source_id: (row.querySelector('.rule-http-value-source') as HTMLSelectElement).value,
|
||||
operator: op,
|
||||
};
|
||||
// The 'exists' operator has no comparison value.
|
||||
if (op !== 'exists') {
|
||||
r.value = (row.querySelector('.rule-http-value') as HTMLInputElement).value;
|
||||
}
|
||||
rules.push(r);
|
||||
} else {
|
||||
const matchType = (row.querySelector('.rule-match-type') as HTMLSelectElement).value;
|
||||
const appsText = (row.querySelector('.rule-apps') as HTMLTextAreaElement).value.trim();
|
||||
@@ -1114,6 +1293,8 @@ function getAutomationEditorRules() {
|
||||
|
||||
export async function saveAutomationEditor() {
|
||||
const idInput = document.getElementById('automation-editor-id') as HTMLInputElement;
|
||||
if (automationModal.closeIfPristine(idInput.value)) return;
|
||||
|
||||
const nameInput = document.getElementById('automation-editor-name') as HTMLInputElement;
|
||||
const enabledInput = document.getElementById('automation-editor-enabled') as HTMLInputElement;
|
||||
const logicSelect = document.getElementById('automation-editor-logic') as HTMLSelectElement;
|
||||
|
||||
@@ -914,6 +914,8 @@ export async function saveCalibration() {
|
||||
const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement).value;
|
||||
const error = document.getElementById('calibration-error') as HTMLElement;
|
||||
|
||||
if (calibModal.closeIfPristine(cssMode ? cssId : deviceId)) return;
|
||||
|
||||
if (cssMode) {
|
||||
await _clearCSSTestMode();
|
||||
} else {
|
||||
|
||||
@@ -38,7 +38,7 @@ registerIconEntityType('color_strip_source', makeSimpleIconAdapter<ColorStripSou
|
||||
typeLabelKey: 'device.icon.entity.color_strip_source',
|
||||
typeLabelFallback: 'Color strip',
|
||||
cardSelectors: (id) => [`[data-css-id="${CSS.escape(id)}"]`],
|
||||
bodyExtras: (rec) => ({ source_type: (rec as any)?.source_type ?? 'static' }),
|
||||
bodyExtras: (rec) => ({ source_type: (rec as any)?.source_type ?? 'single_color' }),
|
||||
}));
|
||||
|
||||
/* ── Types ────────────────────────────────────────────────────── */
|
||||
@@ -88,7 +88,7 @@ function _gradientEntityStripHTML(stops: Array<{ position: number; color: number
|
||||
/* ── Non-picture types set ────────────────────────────────────── */
|
||||
|
||||
const NON_PICTURE_TYPES = new Set([
|
||||
'static', 'gradient', 'effect', 'composite', 'mapped',
|
||||
'single_color', 'gradient', 'effect', 'composite', 'mapped',
|
||||
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
|
||||
'math_wave',
|
||||
]);
|
||||
@@ -96,8 +96,8 @@ const NON_PICTURE_TYPES = new Set([
|
||||
/* ── Per-type card property renderers ─────────────────────────── */
|
||||
|
||||
const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||
static: (source, { clockBadge, animBadge }) => {
|
||||
const colorBadge = _bindableColorBadge(source.color, [255, 255, 255], t('color_strip.static_color'));
|
||||
single_color: (source, { clockBadge, animBadge }) => {
|
||||
const colorBadge = _bindableColorBadge(source.color, [255, 255, 255], t('color_strip.single_color'));
|
||||
return `
|
||||
${colorBadge}
|
||||
${animBadge}
|
||||
@@ -312,7 +312,7 @@ function _renderPictureCardProps(source: ColorStripSource, pictureSourceMap: Rec
|
||||
/* ── Main card builder ────────────────────────────────────────── */
|
||||
|
||||
const STRIP_BADGE: Record<string, string> = {
|
||||
static: 'STRIP · COLOR',
|
||||
single_color: 'STRIP · COLOR',
|
||||
gradient: 'STRIP · GRD',
|
||||
effect: 'STRIP · FX',
|
||||
composite: 'STRIP · COMP',
|
||||
@@ -336,7 +336,7 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
|
||||
? `<span class="stream-card-prop stream-card-link" title="${t('color_strip.clock')}" onclick="event.stopPropagation(); navigateToCard('streams','sync','sync-clocks','data-id','${source.clock_id}')">${ICON_CLOCK} ${escapeHtml(clockObj.name)}</span>`
|
||||
: source.clock_id ? `<span class="stream-card-prop">${ICON_CLOCK} ${source.clock_id}</span>` : '';
|
||||
|
||||
const isAnimatable = source.source_type === 'static' || source.source_type === 'gradient';
|
||||
const isAnimatable = source.source_type === 'single_color' || source.source_type === 'gradient';
|
||||
const anim = isAnimatable && source.animation && source.animation.enabled ? source.animation : null;
|
||||
const animBadge = anim
|
||||
? `<span class="stream-card-prop" title="${t('color_strip.animation')}">${ICON_SPARKLES} ${t('color_strip.animation.type.' + anim.type) || anim.type}</span>`
|
||||
|
||||
@@ -194,6 +194,8 @@ export async function closeGradientEditor() {
|
||||
|
||||
export async function saveGradientEntity() {
|
||||
const id = (document.getElementById('gradient-editor-id') as HTMLInputElement).value;
|
||||
if (gradientEditorModal.closeIfPristine(id)) return;
|
||||
|
||||
const name = (document.getElementById('gradient-editor-name') as HTMLInputElement).value.trim();
|
||||
const description = (document.getElementById('gradient-editor-description') as HTMLInputElement).value.trim() || null;
|
||||
const tags = _gradientTagsInput ? _gradientTagsInput.getValue() : [];
|
||||
|
||||
@@ -127,7 +127,7 @@ class CSSEditorModal extends Modal {
|
||||
if (_candlelightWindWidget) { _candlelightWindWidget.destroy(); _candlelightWindWidget = null; }
|
||||
if (_weatherSpeedWidget) { _weatherSpeedWidget.destroy(); _weatherSpeedWidget = null; }
|
||||
if (_weatherTempInfluenceWidget) { _weatherTempInfluenceWidget.destroy(); _weatherTempInfluenceWidget = null; }
|
||||
if (_staticColorWidget) { _staticColorWidget.destroy(); _staticColorWidget = null; }
|
||||
if (_singleColorWidget) { _singleColorWidget.destroy(); _singleColorWidget = null; }
|
||||
if (_effectColorWidget) { _effectColorWidget.destroy(); _effectColorWidget = null; }
|
||||
if (_apiInputFallbackColorWidget) { _apiInputFallbackColorWidget.destroy(); _apiInputFallbackColorWidget = null; }
|
||||
if (_candlelightColorWidget) { _candlelightColorWidget.destroy(); _candlelightColorWidget = null; }
|
||||
@@ -150,7 +150,7 @@ class CSSEditorModal extends Modal {
|
||||
picture_source: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value,
|
||||
interpolation: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
|
||||
smoothing: _smoothingWidget ? JSON.stringify(_smoothingWidget.getValue()) : '0.3',
|
||||
color: _staticColorWidget ? JSON.stringify(_staticColorWidget.getValue()) : '[]',
|
||||
color: _singleColorWidget ? JSON.stringify(_singleColorWidget.getValue()) : '[]',
|
||||
led_count: (document.getElementById('css-editor-led-count') as HTMLInputElement).value,
|
||||
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
|
||||
animation_type: (document.getElementById('css-editor-animation-type') as HTMLInputElement).value,
|
||||
@@ -223,7 +223,7 @@ let _candlelightWindWidget: BindableScalarWidget | null = null;
|
||||
let _weatherSpeedWidget: BindableScalarWidget | null = null;
|
||||
let _weatherTempInfluenceWidget: BindableScalarWidget | null = null;
|
||||
|
||||
let _staticColorWidget: BindableColorWidget | null = null;
|
||||
let _singleColorWidget: BindableColorWidget | null = null;
|
||||
let _effectColorWidget: BindableColorWidget | null = null;
|
||||
let _apiInputFallbackColorWidget: BindableColorWidget | null = null;
|
||||
let _candlelightColorWidget: BindableColorWidget | null = null;
|
||||
@@ -303,7 +303,7 @@ async function configureKCRegions(sourceId: string): Promise<void> {
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
const CSS_TYPE_KEYS = [
|
||||
'picture', 'picture_advanced', 'static', 'gradient',
|
||||
'picture', 'picture_advanced', 'single_color', 'gradient',
|
||||
'effect', 'composite', 'mapped', 'audio',
|
||||
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
|
||||
'game_event', 'math_wave',
|
||||
@@ -341,7 +341,7 @@ function _ensureCSSTypeIconSelect() {
|
||||
const CSS_SECTION_MAP: Record<string, string> = {
|
||||
'picture': 'css-editor-picture-section',
|
||||
'picture_advanced': 'css-editor-picture-section',
|
||||
'static': 'css-editor-static-section',
|
||||
'single_color': 'css-editor-single-color-section',
|
||||
'gradient': 'css-editor-gradient-section',
|
||||
'effect': 'css-editor-effect-section',
|
||||
'composite': 'css-editor-composite-section',
|
||||
@@ -399,7 +399,7 @@ export function onCSSTypeChange() {
|
||||
|
||||
const animSection = document.getElementById('css-editor-animation-section') as HTMLElement;
|
||||
const animTypeSelect = document.getElementById('css-editor-animation-type') as HTMLSelectElement;
|
||||
if (type === 'static' || type === 'gradient') {
|
||||
if (type === 'single_color' || type === 'gradient') {
|
||||
animSection.style.display = '';
|
||||
const opts = type === 'gradient'
|
||||
? ['none','breathing','gradient_shift','wave','noise_perturb','hue_rotate','strobe','sparkle','pulse','candle','rainbow_fade']
|
||||
@@ -417,7 +417,7 @@ export function onCSSTypeChange() {
|
||||
(document.getElementById('css-editor-led-count-group') as HTMLElement).style.display =
|
||||
hasLedCount.includes(type) ? '' : 'none';
|
||||
|
||||
const clockTypes = ['static', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
|
||||
const clockTypes = ['single_color', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
|
||||
(document.getElementById('css-editor-clock-group') as HTMLElement).style.display = clockTypes.includes(type) ? '' : 'none';
|
||||
if (clockTypes.includes(type)) _populateClockDropdown();
|
||||
|
||||
@@ -726,16 +726,16 @@ function _ensureWeatherTempInfluenceWidget(): BindableScalarWidget {
|
||||
return _weatherTempInfluenceWidget;
|
||||
}
|
||||
|
||||
function _ensureStaticColorWidget(): BindableColorWidget {
|
||||
if (!_staticColorWidget) {
|
||||
_staticColorWidget = new BindableColorWidget({
|
||||
function _ensureSingleColorWidget(): BindableColorWidget {
|
||||
if (!_singleColorWidget) {
|
||||
_singleColorWidget = new BindableColorWidget({
|
||||
container: document.getElementById('css-editor-color-container')!,
|
||||
default: [255, 255, 255],
|
||||
valueSources: () => _cachedValueSources,
|
||||
idPrefix: 'css-editor-color',
|
||||
});
|
||||
}
|
||||
return _staticColorWidget;
|
||||
return _singleColorWidget;
|
||||
}
|
||||
|
||||
function _ensureEffectColorWidget(): BindableColorWidget {
|
||||
@@ -988,17 +988,17 @@ function _autoGenerateCSSName() {
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...args: any[]) => any; getPayload: (name: any) => any }> = {
|
||||
static: {
|
||||
single_color: {
|
||||
load(css) {
|
||||
_ensureStaticColorWidget().setValue(css.color);
|
||||
_ensureSingleColorWidget().setValue(css.color);
|
||||
_loadAnimationState(css.animation);
|
||||
},
|
||||
reset() {
|
||||
_ensureStaticColorWidget().setValue([255, 255, 255]);
|
||||
_ensureSingleColorWidget().setValue([255, 255, 255]);
|
||||
_loadAnimationState(null);
|
||||
},
|
||||
getPayload(name) {
|
||||
return { name, color: _ensureStaticColorWidget().getValue(), animation: _getAnimationPayload() };
|
||||
return { name, color: _ensureSingleColorWidget().getValue(), animation: _getAnimationPayload() };
|
||||
},
|
||||
},
|
||||
gradient: {
|
||||
@@ -1417,7 +1417,7 @@ export function getCSSEditorPreviewPayload(sourceType: string): any {
|
||||
const payload = handler.getPayload('__preview__');
|
||||
if (!payload) return null;
|
||||
payload.source_type = sourceType;
|
||||
const clockTypes = ['static', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
|
||||
const clockTypes = ['single_color', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
|
||||
if (clockTypes.includes(sourceType)) {
|
||||
const clockEl = document.getElementById('css-editor-clock') as HTMLInputElement | null;
|
||||
if (clockEl && clockEl.value) payload.clock_id = clockEl.value;
|
||||
@@ -1559,6 +1559,8 @@ export function isCSSEditorDirty() { return cssEditorModal.isDirty(); }
|
||||
|
||||
export async function saveCSSEditor() {
|
||||
const cssId = (document.getElementById('css-editor-id') as HTMLInputElement).value;
|
||||
if (cssEditorModal.closeIfPristine(cssId)) return;
|
||||
|
||||
const name = (document.getElementById('css-editor-name') as HTMLInputElement).value.trim();
|
||||
const sourceType = (document.getElementById('css-editor-type') as HTMLInputElement).value;
|
||||
|
||||
@@ -1571,7 +1573,7 @@ export async function saveCSSEditor() {
|
||||
|
||||
payload.source_type = knownType ? sourceType : 'picture';
|
||||
|
||||
const clockTypes = ['static', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
|
||||
const clockTypes = ['single_color', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
|
||||
if (clockTypes.includes(sourceType)) {
|
||||
payload.clock_id = (document.getElementById('css-editor-clock') as HTMLInputElement).value || null;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import { openAuthedWs } from '../../core/ws-auth.ts';
|
||||
/* ── Preview config builder ───────────────────────────────────── */
|
||||
|
||||
const _PREVIEW_TYPES = new Set([
|
||||
'static', 'gradient', 'effect', 'daylight', 'candlelight', 'notification', 'audio', 'math_wave',
|
||||
'single_color', 'gradient', 'effect', 'daylight', 'candlelight', 'notification', 'audio', 'math_wave',
|
||||
'weather', 'game_event', 'api_input', 'mapped', 'composite', 'processed',
|
||||
]);
|
||||
|
||||
@@ -38,8 +38,8 @@ function _collectPreviewConfig() {
|
||||
const sourceType = (document.getElementById('css-editor-type') as HTMLInputElement).value;
|
||||
if (!_PREVIEW_TYPES.has(sourceType)) return null;
|
||||
let config: any;
|
||||
if (sourceType === 'static') {
|
||||
config = { source_type: 'static', color: hexToRgbArray((document.getElementById('css-editor-color') as HTMLInputElement).value), animation: _getAnimationPayload() };
|
||||
if (sourceType === 'single_color') {
|
||||
config = { source_type: 'single_color', color: hexToRgbArray((document.getElementById('css-editor-color') as HTMLInputElement).value), animation: _getAnimationPayload() };
|
||||
} else if (sourceType === 'gradient') {
|
||||
const stops = getGradientStops();
|
||||
if (stops.length < 2) return null;
|
||||
|
||||
@@ -843,6 +843,8 @@ export function closeDeviceSettingsModal() { settingsModal.close(); }
|
||||
|
||||
export async function saveDeviceSettings() {
|
||||
const deviceId = (document.getElementById('settings-device-id') as HTMLInputElement).value;
|
||||
if (settingsModal.closeIfPristine(deviceId)) return;
|
||||
|
||||
const name = (document.getElementById('settings-device-name') as HTMLInputElement).value.trim();
|
||||
const url = settingsModal._getUrl();
|
||||
|
||||
|
||||
@@ -707,6 +707,8 @@ export async function showGameIntegrationEditor(editId: string | null = null) {
|
||||
|
||||
export async function saveGameIntegration() {
|
||||
const id = (document.getElementById('gi-id') as HTMLInputElement).value;
|
||||
if (giModal.closeIfPristine(id)) return;
|
||||
|
||||
const name = (document.getElementById('gi-name') as HTMLInputElement).value.trim();
|
||||
if (!name) { giModal.showError(t('game_integration.error.name_required')); return; }
|
||||
|
||||
|
||||
@@ -487,6 +487,8 @@ export async function closeHALightEditor(): Promise<void> {
|
||||
|
||||
export async function saveHALightEditor(): Promise<void> {
|
||||
const targetId = (document.getElementById('ha-light-editor-id') as HTMLInputElement).value;
|
||||
if (haLightEditorModal.closeIfPristine(targetId)) return;
|
||||
|
||||
const name = (document.getElementById('ha-light-editor-name') as HTMLInputElement).value.trim();
|
||||
const haSourceId = (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value;
|
||||
const colorSourceRaw = (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value;
|
||||
|
||||
@@ -142,6 +142,8 @@ export async function closeHASourceModal(): Promise<void> {
|
||||
|
||||
export async function saveHASource(): Promise<void> {
|
||||
const id = (document.getElementById('ha-source-id') as HTMLInputElement).value;
|
||||
if (haSourceModal.closeIfPristine(id)) return;
|
||||
|
||||
const name = (document.getElementById('ha-source-name') as HTMLInputElement).value.trim();
|
||||
const host = (document.getElementById('ha-source-host') as HTMLInputElement).value.trim();
|
||||
const token = (document.getElementById('ha-source-token') as HTMLInputElement).value.trim();
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* HTTP Endpoints — CRUD, test, cards.
|
||||
*
|
||||
* An HTTP endpoint is a connection definition only (URL + auth +
|
||||
* headers + timeout). Polling cadence is owned by HTTPValueSource —
|
||||
* one endpoint can back many value sources at different intervals.
|
||||
*
|
||||
* Structurally mirrors `home-assistant-sources.ts` (HA-source CRUD UI):
|
||||
* register icon adapter → modal subclass with dirty-check → save/edit/
|
||||
* clone/delete handlers → card builder → event delegation.
|
||||
*/
|
||||
|
||||
import {
|
||||
_cachedHTTPEndpoints, httpEndpointsCache,
|
||||
} from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import {
|
||||
ICON_EDIT, ICON_TRASH, ICON_EYE, ICON_EYE_OFF,
|
||||
ICON_TEST, ICON_OK, ICON_WARNING,
|
||||
} from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { makeCardIconFields } from '../core/card-icon.ts';
|
||||
import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
import type { HTTPEndpoint, HTTPEndpointWritePayload, HTTPMethod, HTTPTestResponse } from '../types.ts';
|
||||
|
||||
registerIconEntityType('http_endpoint', makeSimpleIconAdapter<HTTPEndpoint>({
|
||||
cache: httpEndpointsCache,
|
||||
endpointPrefix: '/http/endpoints',
|
||||
reload: async () => {
|
||||
if (typeof (window as any).loadIntegrations === 'function') {
|
||||
await (window as any).loadIntegrations();
|
||||
}
|
||||
},
|
||||
typeLabelKey: 'device.icon.entity.http_endpoint',
|
||||
typeLabelFallback: 'HTTP endpoint',
|
||||
cardSelectors: (id) => [
|
||||
`[data-card-section="http-endpoints"] [data-id="${CSS.escape(id)}"]`,
|
||||
],
|
||||
}));
|
||||
|
||||
const ICON_HTTP = `<svg class="icon" viewBox="0 0 24 24">${P.globe}</svg>`;
|
||||
|
||||
// ── Modal ─────────────────────────────────────────────────────
|
||||
|
||||
let _httpTagsInput: TagInput | null = null;
|
||||
let _httpMethodIconSelect: IconSelect | null = null;
|
||||
/** In-memory headers; mirrored into hidden snapshot field. */
|
||||
let _httpHeaders: Array<{ name: string; value: string }> = [];
|
||||
|
||||
class HTTPEndpointModal extends Modal {
|
||||
constructor() { super('http-endpoint-modal'); }
|
||||
|
||||
onForceClose() {
|
||||
if (_httpTagsInput) { _httpTagsInput.destroy(); _httpTagsInput = null; }
|
||||
if (_httpMethodIconSelect) { _httpMethodIconSelect.destroy(); _httpMethodIconSelect = null; }
|
||||
_httpHeaders = [];
|
||||
const out = document.getElementById('http-endpoint-test-output');
|
||||
if (out) { out.innerHTML = ''; out.style.display = 'none'; }
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
// Headers are compared as a sorted snapshot so the dirty-check is
|
||||
// immune to a backend serialization that emits keys in a different
|
||||
// order than the user originally entered them (false-positive
|
||||
// "discard changes?" on reopen of multi-header endpoints).
|
||||
const headersSnapshot = [..._httpHeaders]
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return {
|
||||
name: (document.getElementById('http-endpoint-name') as HTMLInputElement).value,
|
||||
url: (document.getElementById('http-endpoint-url') as HTMLInputElement).value,
|
||||
method: (document.getElementById('http-endpoint-method') as HTMLSelectElement).value,
|
||||
auth_token: (document.getElementById('http-endpoint-auth-token') as HTMLInputElement).value,
|
||||
timeout_s: (document.getElementById('http-endpoint-timeout') as HTMLInputElement).value,
|
||||
description: (document.getElementById('http-endpoint-description') as HTMLInputElement).value,
|
||||
headers: JSON.stringify(headersSnapshot),
|
||||
tags: JSON.stringify(_httpTagsInput ? _httpTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const httpEndpointModal = new HTTPEndpointModal();
|
||||
|
||||
// ── Method IconSelect (GET / HEAD) ────────────────────────────
|
||||
|
||||
function _ensureMethodIconSelect() {
|
||||
const sel = document.getElementById('http-endpoint-method') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const items = [
|
||||
{
|
||||
value: 'GET',
|
||||
icon: `<svg class="icon" viewBox="0 0 24 24">${P.download}</svg>`,
|
||||
label: 'GET',
|
||||
desc: t('http_endpoint.method.get.desc'),
|
||||
},
|
||||
{
|
||||
value: 'HEAD',
|
||||
icon: `<svg class="icon" viewBox="0 0 24 24">${P.listChecks}</svg>`,
|
||||
label: 'HEAD',
|
||||
desc: t('http_endpoint.method.head.desc'),
|
||||
},
|
||||
];
|
||||
if (_httpMethodIconSelect) { _httpMethodIconSelect.updateItems(items); return; }
|
||||
_httpMethodIconSelect = new IconSelect({ target: sel, items, columns: 2 });
|
||||
}
|
||||
|
||||
// ── Headers list (key/value rows) ─────────────────────────────
|
||||
// Visual model mirrors `.group-child-row` (cards.css): bordered rows on
|
||||
// `--bg-color` with a subtle hover ring, inputs share the same height
|
||||
// and rounded corners, trash button on the right. Empty state matches
|
||||
// the `.pp-filter-empty` dashed-border pattern.
|
||||
|
||||
function _renderHeaderRows() {
|
||||
const list = document.getElementById('http-endpoint-headers-list');
|
||||
if (!list) return;
|
||||
if (_httpHeaders.length === 0) {
|
||||
list.innerHTML = `<div class="http-headers-empty">${escapeHtml(t('http_endpoint.headers.empty'))}</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = _httpHeaders.map((h, idx) => `
|
||||
<div class="http-header-row" data-idx="${idx}">
|
||||
<span class="http-header-index" aria-hidden="true">${idx + 1}</span>
|
||||
<div class="http-header-fields">
|
||||
<input type="text" class="http-header-name" placeholder="${escapeHtml(t('http_endpoint.headers.name_placeholder'))}" value="${escapeHtml(h.name)}" spellcheck="false" autocomplete="off">
|
||||
<input type="text" class="http-header-value" placeholder="${escapeHtml(t('http_endpoint.headers.value_placeholder'))}" value="${escapeHtml(h.value)}" spellcheck="false" autocomplete="off">
|
||||
</div>
|
||||
<button type="button" class="btn btn-icon btn-secondary http-header-remove" title="${escapeHtml(t('common.delete'))}" aria-label="${escapeHtml(t('common.delete'))}">${ICON_TRASH}</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
list.querySelectorAll<HTMLInputElement>('.http-header-name').forEach((inp) => {
|
||||
inp.addEventListener('input', () => {
|
||||
const row = inp.closest<HTMLElement>('.http-header-row')!;
|
||||
const idx = parseInt(row.dataset.idx || '0', 10);
|
||||
if (_httpHeaders[idx]) _httpHeaders[idx].name = inp.value;
|
||||
});
|
||||
});
|
||||
list.querySelectorAll<HTMLInputElement>('.http-header-value').forEach((inp) => {
|
||||
inp.addEventListener('input', () => {
|
||||
const row = inp.closest<HTMLElement>('.http-header-row')!;
|
||||
const idx = parseInt(row.dataset.idx || '0', 10);
|
||||
if (_httpHeaders[idx]) _httpHeaders[idx].value = inp.value;
|
||||
});
|
||||
});
|
||||
list.querySelectorAll<HTMLButtonElement>('.http-header-remove').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest<HTMLElement>('.http-header-row')!;
|
||||
const idx = parseInt(row.dataset.idx || '0', 10);
|
||||
_httpHeaders.splice(idx, 1);
|
||||
_renderHeaderRows();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function addHTTPEndpointHeader() {
|
||||
_httpHeaders.push({ name: '', value: '' });
|
||||
_renderHeaderRows();
|
||||
// Focus the newest row so the user can immediately start typing.
|
||||
const list = document.getElementById('http-endpoint-headers-list');
|
||||
if (list) {
|
||||
const names = list.querySelectorAll<HTMLInputElement>('.http-header-name');
|
||||
names[names.length - 1]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Show / Close ──────────────────────────────────────────────
|
||||
|
||||
export async function showHTTPEndpointModal(editData: HTTPEndpoint | null = null): Promise<void> {
|
||||
const isEdit = !!editData;
|
||||
const titleKey = isEdit ? 'http_endpoint.edit' : 'http_endpoint.add';
|
||||
document.getElementById('http-endpoint-modal-title')!.innerHTML = `${ICON_HTTP} ${t(titleKey)}`;
|
||||
(document.getElementById('http-endpoint-id') as HTMLInputElement).value = editData?.id || '';
|
||||
(document.getElementById('http-endpoint-error') as HTMLElement).style.display = 'none';
|
||||
|
||||
const nameInput = document.getElementById('http-endpoint-name') as HTMLInputElement;
|
||||
const urlInput = document.getElementById('http-endpoint-url') as HTMLInputElement;
|
||||
const methodSel = document.getElementById('http-endpoint-method') as HTMLSelectElement;
|
||||
const tokenInput = document.getElementById('http-endpoint-auth-token') as HTMLInputElement;
|
||||
const timeoutInput = document.getElementById('http-endpoint-timeout') as HTMLInputElement;
|
||||
const descInput = document.getElementById('http-endpoint-description') as HTMLInputElement;
|
||||
|
||||
nameInput.value = editData?.name || '';
|
||||
urlInput.value = editData?.url || '';
|
||||
methodSel.value = editData?.method || 'GET';
|
||||
tokenInput.value = ''; // never expose stored token
|
||||
tokenInput.type = 'password';
|
||||
timeoutInput.value = String(editData?.timeout_s ?? 10);
|
||||
descInput.value = editData?.description || '';
|
||||
|
||||
// Headers: snapshot from data into our editable buffer
|
||||
_httpHeaders = editData?.headers
|
||||
? Object.entries(editData.headers).map(([name, value]) => ({ name, value }))
|
||||
: [];
|
||||
_renderHeaderRows();
|
||||
|
||||
_ensureMethodIconSelect();
|
||||
if (_httpMethodIconSelect) _httpMethodIconSelect.setValue(editData?.method || 'GET');
|
||||
|
||||
// Show "leave blank to keep" hint only when editing an endpoint
|
||||
// that already has a token configured.
|
||||
const tokenHint = document.getElementById('http-endpoint-token-hint');
|
||||
if (tokenHint) tokenHint.style.display = (isEdit && editData?.auth_token_set) ? '' : 'none';
|
||||
|
||||
// Reveal password toggle
|
||||
const revealBtn = document.getElementById('http-endpoint-token-reveal');
|
||||
if (revealBtn) revealBtn.innerHTML = ICON_EYE;
|
||||
|
||||
// Inject icon into the inline Test button
|
||||
const testBtnIcon = document.querySelector('#http-endpoint-test-btn .http-endpoint-test-btn-icon');
|
||||
if (testBtnIcon) testBtnIcon.innerHTML = ICON_TEST;
|
||||
|
||||
// Reset test output
|
||||
const out = document.getElementById('http-endpoint-test-output');
|
||||
if (out) { out.innerHTML = ''; out.style.display = 'none'; }
|
||||
|
||||
// Tags
|
||||
if (_httpTagsInput) { _httpTagsInput.destroy(); _httpTagsInput = null; }
|
||||
_httpTagsInput = new TagInput(
|
||||
document.getElementById('http-endpoint-tags-container'),
|
||||
{ placeholder: t('tags.placeholder') }
|
||||
);
|
||||
_httpTagsInput.setValue(isEdit ? (editData?.tags || []) : []);
|
||||
|
||||
httpEndpointModal.open();
|
||||
httpEndpointModal.snapshot();
|
||||
}
|
||||
|
||||
export async function closeHTTPEndpointModal(): Promise<void> {
|
||||
await httpEndpointModal.close();
|
||||
}
|
||||
|
||||
export function toggleHTTPEndpointTokenVisibility() {
|
||||
const inp = document.getElementById('http-endpoint-auth-token') as HTMLInputElement;
|
||||
const btn = document.getElementById('http-endpoint-token-reveal');
|
||||
if (!inp || !btn) return;
|
||||
if (inp.type === 'password') {
|
||||
inp.type = 'text';
|
||||
btn.innerHTML = ICON_EYE_OFF;
|
||||
} else {
|
||||
inp.type = 'password';
|
||||
btn.innerHTML = ICON_EYE;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Header collection (commits live <input> values to buffer) ─
|
||||
|
||||
function _collectHeaders(): Record<string, string> {
|
||||
// Pull current input values to be safe (in case of paste / IME).
|
||||
const list = document.getElementById('http-endpoint-headers-list');
|
||||
if (list) {
|
||||
list.querySelectorAll<HTMLElement>('.http-header-row').forEach((row) => {
|
||||
const idx = parseInt(row.dataset.idx || '0', 10);
|
||||
const name = (row.querySelector('.http-header-name') as HTMLInputElement)?.value || '';
|
||||
const value = (row.querySelector('.http-header-value') as HTMLInputElement)?.value || '';
|
||||
if (_httpHeaders[idx]) { _httpHeaders[idx].name = name; _httpHeaders[idx].value = value; }
|
||||
});
|
||||
}
|
||||
const out: Record<string, string> = {};
|
||||
for (const h of _httpHeaders) {
|
||||
const name = h.name.trim();
|
||||
if (!name) continue;
|
||||
out[name] = h.value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────
|
||||
|
||||
export async function saveHTTPEndpoint(): Promise<void> {
|
||||
const id = (document.getElementById('http-endpoint-id') as HTMLInputElement).value;
|
||||
if (httpEndpointModal.closeIfPristine(id)) return;
|
||||
|
||||
const name = (document.getElementById('http-endpoint-name') as HTMLInputElement).value.trim();
|
||||
const url = (document.getElementById('http-endpoint-url') as HTMLInputElement).value.trim();
|
||||
const method = (document.getElementById('http-endpoint-method') as HTMLSelectElement).value as HTTPMethod;
|
||||
const token = (document.getElementById('http-endpoint-auth-token') as HTMLInputElement).value;
|
||||
const timeoutRaw = (document.getElementById('http-endpoint-timeout') as HTMLInputElement).value;
|
||||
const description = (document.getElementById('http-endpoint-description') as HTMLInputElement).value.trim() || undefined;
|
||||
const headers = _collectHeaders();
|
||||
const timeout_s = parseFloat(timeoutRaw);
|
||||
|
||||
if (!name) {
|
||||
httpEndpointModal.showError(t('http_endpoint.error.name_required'));
|
||||
return;
|
||||
}
|
||||
if (!url) {
|
||||
httpEndpointModal.showError(t('http_endpoint.error.url_required'));
|
||||
return;
|
||||
}
|
||||
if (isNaN(timeout_s) || timeout_s <= 0) {
|
||||
httpEndpointModal.showError(t('http_endpoint.error.timeout_invalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: HTTPEndpointWritePayload = {
|
||||
name, url, method, headers, timeout_s, description,
|
||||
tags: _httpTagsInput ? _httpTagsInput.getValue() : [],
|
||||
};
|
||||
|
||||
// Auth token semantics (per backend schema):
|
||||
// POST — empty string means "no token"
|
||||
// PUT new — non-empty replaces; empty string CLEARS; omit to KEEP existing.
|
||||
// For PUT we omit the field unless the user typed something so we don't
|
||||
// accidentally clear a previously-configured token.
|
||||
if (!id) {
|
||||
payload.auth_token = token;
|
||||
} else if (token) {
|
||||
payload.auth_token = token;
|
||||
}
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/http/endpoints/${id}` : '/http/endpoints';
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t(id ? 'http_endpoint.updated' : 'http_endpoint.created'), 'success');
|
||||
httpEndpointModal.forceClose();
|
||||
httpEndpointsCache.invalidate();
|
||||
if (typeof (window as any).loadIntegrations === 'function') await (window as any).loadIntegrations();
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
httpEndpointModal.showError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit / Clone / Delete ─────────────────────────────────────
|
||||
|
||||
export async function editHTTPEndpoint(endpointId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`);
|
||||
if (!resp.ok) throw new Error(t('http_endpoint.error.load'));
|
||||
const data: HTTPEndpoint = await resp.json();
|
||||
await showHTTPEndpointModal(data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function cloneHTTPEndpoint(endpointId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`);
|
||||
if (!resp.ok) throw new Error(t('http_endpoint.error.load'));
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
// Cloning never reveals the token — user must re-enter if needed.
|
||||
data.auth_token_set = false;
|
||||
await showHTTPEndpointModal(data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteHTTPEndpoint(endpointId: string): Promise<void> {
|
||||
const confirmed = await showConfirm(t('http_endpoint.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('http_endpoint.deleted'), 'success');
|
||||
httpEndpointsCache.invalidate();
|
||||
if (typeof (window as any).loadIntegrations === 'function') await (window as any).loadIntegrations();
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test (in-form, pre-save) ──────────────────────────────────
|
||||
|
||||
/** Builds a `HTTPTestRequest` from the live form values and renders
|
||||
* the response inline. Works for both new and edit modes. */
|
||||
export async function testHTTPEndpoint(): Promise<void> {
|
||||
const url = (document.getElementById('http-endpoint-url') as HTMLInputElement).value.trim();
|
||||
const method = (document.getElementById('http-endpoint-method') as HTMLSelectElement).value as HTTPMethod;
|
||||
const token = (document.getElementById('http-endpoint-auth-token') as HTMLInputElement).value;
|
||||
const timeoutRaw = (document.getElementById('http-endpoint-timeout') as HTMLInputElement).value;
|
||||
const headers = _collectHeaders();
|
||||
const timeout_s = parseFloat(timeoutRaw);
|
||||
|
||||
const out = document.getElementById('http-endpoint-test-output');
|
||||
if (!out) return;
|
||||
|
||||
// Render validation errors inline next to the Test button — the
|
||||
// modal-level banner at the top of the form is invisible from here.
|
||||
const renderValidationFail = (msg: string): void => {
|
||||
out.style.display = '';
|
||||
out.innerHTML = `<div class="http-test-result http-test-fail">
|
||||
<span class="http-test-badge http-test-badge-fail">${escapeHtml(t('http_endpoint.test.failed'))}</span>
|
||||
<code class="http-test-error">${escapeHtml(msg)}</code>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
if (!url) {
|
||||
renderValidationFail(t('http_endpoint.error.url_required'));
|
||||
return;
|
||||
}
|
||||
if (isNaN(timeout_s) || timeout_s <= 0) {
|
||||
renderValidationFail(t('http_endpoint.error.timeout_invalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
const testBtn = document.getElementById('http-endpoint-test-btn');
|
||||
if (testBtn) testBtn.classList.add('loading');
|
||||
out.style.display = '';
|
||||
out.innerHTML = `<div class="http-test-pending">
|
||||
<span class="http-test-pending-spinner" aria-hidden="true"></span>
|
||||
<span>${escapeHtml(t('http_endpoint.test.pending'))}</span>
|
||||
</div>`;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/http/endpoints/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, method, auth_token: token, headers, timeout_s }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data: HTTPTestResponse = await resp.json();
|
||||
_renderTestResult(out, data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
out.innerHTML = _renderTestErrorHtml(e.message);
|
||||
} finally {
|
||||
if (testBtn) testBtn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
function _renderTestErrorHtml(message: string): string {
|
||||
return `<div class="http-test-result http-test-fail">
|
||||
<div class="http-test-line">
|
||||
<span class="http-test-badge http-test-badge-fail">${ICON_WARNING}<span>${escapeHtml(t('http_endpoint.test.failed'))}</span></span>
|
||||
</div>
|
||||
<code class="http-test-error">${escapeHtml(message)}</code>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderTestResult(out: HTMLElement, data: HTTPTestResponse) {
|
||||
const statusClass = data.success ? 'http-test-ok' : 'http-test-fail';
|
||||
const badgeClass = data.success ? 'http-test-badge-ok' : 'http-test-badge-fail';
|
||||
const badgeIcon = data.success ? ICON_OK : ICON_WARNING;
|
||||
const badgeText = data.success
|
||||
? t('http_endpoint.test.success')
|
||||
: t('http_endpoint.test.failed');
|
||||
const statusLine = data.status_code != null
|
||||
? `<span class="http-test-status" title="HTTP ${data.status_code}">${data.status_code}</span>`
|
||||
: '';
|
||||
const errLine = data.error && !data.success
|
||||
? `<code class="http-test-error">${escapeHtml(data.error)}</code>`
|
||||
: '';
|
||||
// Show JSON body when available — easier to copy a json_path from than raw text.
|
||||
let bodyHtml = '';
|
||||
let bodyLabel = '';
|
||||
if (data.body_json != null) {
|
||||
let pretty: string;
|
||||
try { pretty = JSON.stringify(data.body_json, null, 2); }
|
||||
catch { pretty = String(data.body_json); }
|
||||
bodyLabel = `<div class="http-test-body-label">${escapeHtml(t('http_endpoint.test.body.json'))}</div>`;
|
||||
bodyHtml = `<pre class="http-test-body">${escapeHtml(pretty)}</pre>`;
|
||||
} else if (data.body_preview) {
|
||||
bodyLabel = `<div class="http-test-body-label">${escapeHtml(t('http_endpoint.test.body.text'))}</div>`;
|
||||
bodyHtml = `<pre class="http-test-body">${escapeHtml(data.body_preview)}</pre>`;
|
||||
}
|
||||
out.innerHTML = `<div class="http-test-result ${statusClass}">
|
||||
<div class="http-test-line">
|
||||
<span class="http-test-badge ${badgeClass}">${badgeIcon}<span>${escapeHtml(badgeText)}</span></span>
|
||||
${statusLine}
|
||||
</div>
|
||||
${errLine}
|
||||
${bodyLabel}
|
||||
${bodyHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Card-level test (uses stored config, no form needed) ──────
|
||||
|
||||
async function _testHTTPEndpointFromCard(endpointId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}/test`, { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
const data: HTTPTestResponse = await resp.json();
|
||||
if (data.success) {
|
||||
const status = data.status_code != null ? ` (${data.status_code})` : '';
|
||||
showToast(`${t('http_endpoint.test.success')}${status}`, 'success');
|
||||
} else {
|
||||
const detail = data.error || `HTTP ${data.status_code ?? '?'}`;
|
||||
showToast(`${t('http_endpoint.test.failed')}: ${detail}`, 'error');
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(`${t('http_endpoint.test.failed')}: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Card rendering ────────────────────────────────────────────
|
||||
|
||||
export function createHTTPEndpointCard(endpoint: HTTPEndpoint) {
|
||||
const hasAuth = !!endpoint.auth_token_set;
|
||||
const headerCount = Object.keys(endpoint.headers || {}).length;
|
||||
const leds: LedState[] = ['on'];
|
||||
|
||||
const chips: ModChipOpts[] = [
|
||||
{
|
||||
icon: `<svg class="icon" viewBox="0 0 24 24">${P.globe}</svg>`,
|
||||
text: endpoint.url,
|
||||
title: endpoint.url,
|
||||
},
|
||||
{
|
||||
icon: `<svg class="icon" viewBox="0 0 24 24">${P.refreshCw}</svg>`,
|
||||
text: endpoint.method,
|
||||
},
|
||||
];
|
||||
if (hasAuth) {
|
||||
chips.push({
|
||||
icon: `<svg class="icon" viewBox="0 0 24 24">${P.lock}</svg>`,
|
||||
text: t('http_endpoint.auth.set'),
|
||||
});
|
||||
}
|
||||
if (headerCount > 0) {
|
||||
chips.push({
|
||||
icon: `<svg class="icon" viewBox="0 0 24 24">${P.listChecks}</svg>`,
|
||||
text: t('http_endpoint.headers.count').replace('{n}', String(headerCount)),
|
||||
});
|
||||
}
|
||||
|
||||
const mod: ModCardOpts = {
|
||||
head: {
|
||||
badge: { text: 'HTTP · ENDPOINT' },
|
||||
name: endpoint.name,
|
||||
metaHtml: escapeHtml(endpoint.url),
|
||||
leds,
|
||||
...makeCardIconFields('http_endpoint', endpoint.id, endpoint),
|
||||
menu: {
|
||||
duplicateOnclick: `cloneHTTPEndpoint('${endpoint.id}')`,
|
||||
hideOnclick: `toggleCardHidden('http-endpoints','${endpoint.id}')`,
|
||||
deleteOnclick: `deleteHTTPEndpoint('${endpoint.id}')`,
|
||||
},
|
||||
},
|
||||
body: {
|
||||
desc: endpoint.description || undefined,
|
||||
chips,
|
||||
},
|
||||
foot: {
|
||||
patchState: 'idle',
|
||||
patchLabel: `${endpoint.method} · ${Math.round(endpoint.timeout_s)}s`,
|
||||
iconActions: [
|
||||
{ icon: ICON_TEST, onclick: '', title: t('http_endpoint.test'), dataAttrs: { 'data-action': 'test' } },
|
||||
{ icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } },
|
||||
],
|
||||
},
|
||||
running: false,
|
||||
};
|
||||
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: endpoint.id, mod });
|
||||
const tagsHtml = renderTagChips(endpoint.tags);
|
||||
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
|
||||
}
|
||||
|
||||
// ── Event delegation ──────────────────────────────────────────
|
||||
|
||||
const _httpEndpointActions: Record<string, (id: string) => void> = {
|
||||
clone: cloneHTTPEndpoint,
|
||||
edit: editHTTPEndpoint,
|
||||
test: _testHTTPEndpointFromCard,
|
||||
};
|
||||
|
||||
export function initHTTPEndpointDelegation(container: HTMLElement): void {
|
||||
container.addEventListener('click', (e: MouseEvent) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
|
||||
if (!btn) return;
|
||||
|
||||
const section = btn.closest<HTMLElement>('[data-card-section="http-endpoints"]');
|
||||
if (!section) return;
|
||||
const card = btn.closest<HTMLElement>('[data-id]');
|
||||
if (!card) return;
|
||||
|
||||
const action = btn.dataset.action;
|
||||
const id = card.getAttribute('data-id');
|
||||
if (!action || !id) return;
|
||||
|
||||
const handler = _httpEndpointActions[action];
|
||||
if (handler) {
|
||||
e.stopPropagation();
|
||||
handler(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Expose to global scope for HTML onclick handlers ──────────
|
||||
|
||||
window.showHTTPEndpointModal = showHTTPEndpointModal;
|
||||
window.closeHTTPEndpointModal = closeHTTPEndpointModal;
|
||||
window.saveHTTPEndpoint = saveHTTPEndpoint;
|
||||
window.editHTTPEndpoint = editHTTPEndpoint;
|
||||
window.cloneHTTPEndpoint = cloneHTTPEndpoint;
|
||||
window.deleteHTTPEndpoint = deleteHTTPEndpoint;
|
||||
window.testHTTPEndpoint = testHTTPEndpoint;
|
||||
window.addHTTPEndpointHeader = addHTTPEndpointHeader;
|
||||
window.toggleHTTPEndpointTokenVisibility = toggleHTTPEndpointTokenVisibility;
|
||||
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
_cachedWeatherSources, _cachedHASources, _cachedMQTTSources,
|
||||
_cachedWeatherSources, _cachedHASources, _cachedMQTTSources, _cachedHTTPEndpoints,
|
||||
_cachedGameIntegrations, _cachedGameAdapters,
|
||||
weatherSourcesCache, haSourcesCache, mqttSourcesCache,
|
||||
weatherSourcesCache, haSourcesCache, mqttSourcesCache, httpEndpointsCache,
|
||||
gameIntegrationsCache, gameAdaptersCache,
|
||||
apiKey,
|
||||
} from '../core/state.ts';
|
||||
@@ -20,6 +20,7 @@ import { showToast, setTabRefreshing } from '../core/ui.ts';
|
||||
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
|
||||
import { createHASourceCard, initHASourceDelegation } from './home-assistant-sources.ts';
|
||||
import { createMQTTSourceCard, initMQTTSourceDelegation } from './mqtt-sources.ts';
|
||||
import { createHTTPEndpointCard, initHTTPEndpointDelegation } from './http-endpoints.ts';
|
||||
import { createGameIntegrationCard, csGameIntegrations } from './game-integration.ts';
|
||||
import { ICON_GAMEPAD, ICON_TRASH, ICON_HELP } from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
@@ -42,12 +43,14 @@ function _bulkDeleteFactory(endpoint: string, cache: any, toast: string) {
|
||||
const _weatherSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('weather-sources', weatherSourcesCache, 'weather_source.deleted') }];
|
||||
const _haSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('home-assistant/sources', haSourcesCache, 'ha_source.deleted') }];
|
||||
const _mqttSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('mqtt/sources', mqttSourcesCache, 'mqtt_source.deleted') }];
|
||||
const _httpEndpointDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('http/endpoints', httpEndpointsCache, 'http_endpoint.deleted') }];
|
||||
|
||||
// ── Card section instances ──
|
||||
|
||||
const csWeatherSources = new CardSection('weather-sources', { titleKey: 'weather_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showWeatherSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.weather_sources', bulkActions: _weatherSourceDeleteAction });
|
||||
const csHASources = new CardSection('ha-sources', { titleKey: 'ha_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showHASourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.ha_sources', bulkActions: _haSourceDeleteAction });
|
||||
const csMQTTSources = new CardSection('mqtt-sources', { titleKey: 'mqtt_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showMQTTSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.mqtt_sources', bulkActions: _mqttSourceDeleteAction });
|
||||
const csHTTPEndpoints = new CardSection('http-endpoints', { titleKey: 'http_endpoint.group.title', gridClass: 'templates-grid', addCardOnclick: "showHTTPEndpointModal()", keyAttr: 'data-id', emptyKey: 'section.empty.http_endpoints', bulkActions: _httpEndpointDeleteAction });
|
||||
|
||||
// Re-render integrations when language changes
|
||||
document.addEventListener('languageChanged', () => { if (apiKey) loadIntegrations(); });
|
||||
@@ -87,6 +90,7 @@ const _integrationSectionMap: Record<string, CardSection[]> = {
|
||||
weather: [csWeatherSources],
|
||||
home_assistant: [csHASources],
|
||||
mqtt: [csMQTTSources],
|
||||
http: [csHTTPEndpoints],
|
||||
game: [csGameIntegrations],
|
||||
};
|
||||
|
||||
@@ -101,6 +105,7 @@ export async function loadIntegrations() {
|
||||
weatherSourcesCache.fetch(),
|
||||
haSourcesCache.fetch(),
|
||||
mqttSourcesCache.fetch(),
|
||||
httpEndpointsCache.fetch(),
|
||||
gameIntegrationsCache.fetch(),
|
||||
gameAdaptersCache.fetch(),
|
||||
]);
|
||||
@@ -127,6 +132,7 @@ function renderIntegrationsList() {
|
||||
{ key: 'weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
|
||||
{ key: 'home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, titleKey: 'streams.group.home_assistant', count: _cachedHASources.length },
|
||||
{ key: 'mqtt', icon: `<svg class="icon" viewBox="0 0 24 24">${P.radio}</svg>`, titleKey: 'streams.group.mqtt', count: _cachedMQTTSources.length },
|
||||
{ key: 'http', icon: `<svg class="icon" viewBox="0 0 24 24">${P.globe}</svg>`, titleKey: 'streams.group.http', count: _cachedHTTPEndpoints.length },
|
||||
{ key: 'game', icon: ICON_GAMEPAD, titleKey: 'streams.group.game', count: _cachedGameIntegrations.length },
|
||||
];
|
||||
|
||||
@@ -135,6 +141,7 @@ function renderIntegrationsList() {
|
||||
{ key: 'weather', titleKey: 'streams.group.weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
|
||||
{ key: 'home_assistant', titleKey: 'streams.group.home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, count: _cachedHASources.length },
|
||||
{ key: 'mqtt', titleKey: 'streams.group.mqtt', icon: `<svg class="icon" viewBox="0 0 24 24">${P.radio}</svg>`, count: _cachedMQTTSources.length },
|
||||
{ key: 'http', titleKey: 'streams.group.http', icon: `<svg class="icon" viewBox="0 0 24 24">${P.globe}</svg>`, count: _cachedHTTPEndpoints.length },
|
||||
{ key: 'game', titleKey: 'streams.group.game', icon: ICON_GAMEPAD, count: _cachedGameIntegrations.length },
|
||||
];
|
||||
|
||||
@@ -142,6 +149,7 @@ function renderIntegrationsList() {
|
||||
const weatherSourceItems = csWeatherSources.applySortOrder(_cachedWeatherSources.map(s => ({ key: s.id, html: createWeatherSourceCard(s) })));
|
||||
const haSourceItems = csHASources.applySortOrder(_cachedHASources.map(s => ({ key: s.id, html: createHASourceCard(s) })));
|
||||
const mqttSourceItems = csMQTTSources.applySortOrder(_cachedMQTTSources.map(s => ({ key: s.id, html: createMQTTSourceCard(s) })));
|
||||
const httpEndpointItems = csHTTPEndpoints.applySortOrder(_cachedHTTPEndpoints.map(e => ({ key: e.id, html: createHTTPEndpointCard(e) })));
|
||||
const gameIntegrationItems = csGameIntegrations.applySortOrder(_cachedGameIntegrations.map(g => ({ key: g.id, html: createGameIntegrationCard(g) })));
|
||||
|
||||
if (csWeatherSources.isMounted()) {
|
||||
@@ -150,11 +158,13 @@ function renderIntegrationsList() {
|
||||
weather: _cachedWeatherSources.length,
|
||||
home_assistant: _cachedHASources.length,
|
||||
mqtt: _cachedMQTTSources.length,
|
||||
http: _cachedHTTPEndpoints.length,
|
||||
game: _cachedGameIntegrations.length,
|
||||
});
|
||||
csWeatherSources.reconcile(weatherSourceItems);
|
||||
csHASources.reconcile(haSourceItems);
|
||||
csMQTTSources.reconcile(mqttSourceItems);
|
||||
csHTTPEndpoints.reconcile(httpEndpointItems);
|
||||
csGameIntegrations.reconcile(gameIntegrationItems);
|
||||
} else {
|
||||
// First render: build full HTML
|
||||
@@ -163,17 +173,19 @@ function renderIntegrationsList() {
|
||||
if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
|
||||
else if (tab.key === 'home_assistant') panelContent = csHASources.render(haSourceItems);
|
||||
else if (tab.key === 'mqtt') panelContent = csMQTTSources.render(mqttSourceItems);
|
||||
else if (tab.key === 'http') panelContent = csHTTPEndpoints.render(httpEndpointItems);
|
||||
else if (tab.key === 'game') panelContent = csGameIntegrations.render(gameIntegrationItems);
|
||||
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="integration-tab-${tab.key}">${panelContent}</div>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = panels;
|
||||
CardSection.bindAll([csWeatherSources, csHASources, csMQTTSources, csGameIntegrations]);
|
||||
CardSection.bindAll([csWeatherSources, csHASources, csMQTTSources, csHTTPEndpoints, csGameIntegrations]);
|
||||
|
||||
// Event delegation for card actions
|
||||
initWeatherSourceDelegation(container);
|
||||
initHASourceDelegation(container);
|
||||
initMQTTSourceDelegation(container);
|
||||
initHTTPEndpointDelegation(container);
|
||||
|
||||
// Render tree sidebar with tutorial trigger button
|
||||
_integrationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startIntegrationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
|
||||
@@ -182,6 +194,7 @@ function renderIntegrationsList() {
|
||||
'weather-sources': 'weather',
|
||||
'ha-sources': 'home_assistant',
|
||||
'mqtt-sources': 'mqtt',
|
||||
'http-endpoints': 'http',
|
||||
'game-integrations': 'game',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -115,6 +115,8 @@ export async function closeMQTTSourceModal(): Promise<void> {
|
||||
|
||||
export async function saveMQTTSource(): Promise<void> {
|
||||
const id = (document.getElementById('mqtt-source-id') as HTMLInputElement).value;
|
||||
if (mqttSourceModal.closeIfPristine(id)) return;
|
||||
|
||||
const name = (document.getElementById('mqtt-source-name') as HTMLInputElement).value.trim();
|
||||
const broker_host = (document.getElementById('mqtt-source-host') as HTMLInputElement).value.trim();
|
||||
const broker_port = parseInt((document.getElementById('mqtt-source-port') as HTMLInputElement).value, 10) || 1883;
|
||||
|
||||
@@ -240,6 +240,8 @@ export async function savePatternTemplate(): Promise<void> {
|
||||
}
|
||||
|
||||
const templateId = (document.getElementById('pattern-template-id') as HTMLInputElement).value;
|
||||
if (patternModal.closeIfPristine(templateId)) return;
|
||||
|
||||
const name = (document.getElementById('pattern-template-name') as HTMLInputElement).value.trim();
|
||||
const description = (document.getElementById('pattern-template-description') as HTMLInputElement).value.trim();
|
||||
|
||||
|
||||
@@ -369,6 +369,8 @@ export async function editScenePreset(presetId: string): Promise<void> {
|
||||
// ===== Save (create or update) =====
|
||||
|
||||
export async function saveScenePreset(): Promise<void> {
|
||||
if (scenePresetModal.closeIfPristine(_editingId)) return;
|
||||
|
||||
const name = (document.getElementById('scene-preset-editor-name') as HTMLInputElement).value.trim();
|
||||
const description = (document.getElementById('scene-preset-editor-description') as HTMLInputElement).value.trim();
|
||||
const errorEl = document.getElementById('scene-preset-editor-error')!;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { TagInput } from '../core/tag-input.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
import { enhanceMiniSelects } from '../core/mini-select.ts';
|
||||
import { loadPictureSources } from './streams.ts';
|
||||
import { openAuthedWs } from '../core/ws-auth.ts';
|
||||
|
||||
@@ -150,6 +151,11 @@ export async function onAudioEngineChange() {
|
||||
});
|
||||
gridHtml += '</div>';
|
||||
configFields.innerHTML = gridHtml;
|
||||
// Convert the boolean toggles into MiniSelect popups — plain
|
||||
// ``<select>`` is banned project-wide and these are the only
|
||||
// selects rendered here that don't have a dedicated IconSelect
|
||||
// configuration in CONFIG_ICON_SELECT.
|
||||
enhanceMiniSelects(configFields, 'select[data-config-key]');
|
||||
}
|
||||
|
||||
configSection.style.display = 'block';
|
||||
@@ -262,6 +268,8 @@ export async function closeAudioTemplateModal() {
|
||||
|
||||
export async function saveAudioTemplate() {
|
||||
const templateId = currentEditingAudioTemplateId;
|
||||
if (audioTemplateModal.closeIfPristine(templateId)) return;
|
||||
|
||||
const name = (document.getElementById('audio-template-name') as HTMLInputElement).value.trim();
|
||||
const engineType = (document.getElementById('audio-template-engine') as HTMLSelectElement).value;
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import * as P from '../core/icon-paths.ts';
|
||||
import { TagInput } from '../core/tag-input.ts';
|
||||
import { openAuthedWs } from '../core/ws-auth.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
import { enhanceMiniSelects } from '../core/mini-select.ts';
|
||||
import { loadPictureSources } from './streams.ts';
|
||||
|
||||
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
@@ -330,6 +331,12 @@ export async function onEngineChange() {
|
||||
_configIconSelects.set(key, inst);
|
||||
}
|
||||
}
|
||||
// Everything else (booleans + free-form selectOptions without a
|
||||
// CONFIG_ICON_SELECT entry) gets a MiniSelect so plain ``<select>``
|
||||
// never reaches the user. ``enhanceMiniSelects`` skips elements
|
||||
// already hidden by IconSelect so this is safe to run after the
|
||||
// loop above.
|
||||
enhanceMiniSelects(configFields, 'select[data-config-key]');
|
||||
}
|
||||
|
||||
configSection.style.display = 'block';
|
||||
@@ -584,6 +591,8 @@ export function _runTestViaWS(wsPath: string, queryParams: any = {}, firstMessag
|
||||
|
||||
export async function saveTemplate() {
|
||||
const templateId = (document.getElementById('template-id') as HTMLInputElement).value;
|
||||
if (templateModal.closeIfPristine(templateId)) return;
|
||||
|
||||
const name = (document.getElementById('template-name') as HTMLInputElement).value.trim();
|
||||
const engineType = (document.getElementById('template-engine') as HTMLSelectElement).value;
|
||||
|
||||
|
||||
@@ -749,7 +749,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
{ key: 'processed', icon: getPictureSourceIcon('processed'), titleKey: 'streams.group.processed', count: processedStreams.length },
|
||||
{ key: 'proc_templates', icon: ICON_PP_TEMPLATE, titleKey: 'streams.group.proc_templates', count: _cachedPPTemplates.length },
|
||||
{ key: 'css_processing', icon: ICON_CSPT, titleKey: 'streams.group.css_processing', count: csptTemplates.length },
|
||||
{ key: 'color_strip', icon: getColorStripIcon('static'), titleKey: 'streams.group.color_strip', count: colorStrips.length },
|
||||
{ key: 'color_strip', icon: getColorStripIcon('single_color'), titleKey: 'streams.group.color_strip', count: colorStrips.length },
|
||||
{ key: 'gradients', icon: ICON_PALETTE, titleKey: 'streams.group.gradients', count: gradients.length },
|
||||
{ key: 'audio_capture', icon: getAudioSourceIcon('capture'), titleKey: 'audio_source.group.capture', count: captureSources.length },
|
||||
{ key: 'audio_processed', icon: getAudioSourceIcon('processed'), titleKey: 'audio_source.group.processed', count: processedAudioSources.length },
|
||||
@@ -789,9 +789,9 @@ function renderPictureSourcesList(streams: any) {
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'strip_group', icon: getColorStripIcon('static'), titleKey: 'tree.group.strip',
|
||||
key: 'strip_group', icon: getColorStripIcon('single_color'), titleKey: 'tree.group.strip',
|
||||
children: [
|
||||
{ key: 'color_strip', titleKey: 'tree.leaf.sources', icon: getColorStripIcon('static'), count: colorStrips.length },
|
||||
{ key: 'color_strip', titleKey: 'tree.leaf.sources', icon: getColorStripIcon('single_color'), count: colorStrips.length },
|
||||
{ key: 'gradients', titleKey: 'streams.group.gradients', icon: ICON_PALETTE, count: gradients.length },
|
||||
{ key: 'css_processing', titleKey: 'tree.leaf.processing_templates', icon: ICON_CSPT, count: csptTemplates.length },
|
||||
]
|
||||
@@ -1460,6 +1460,8 @@ async function _refreshStreamDisplaysForEngine(engineType: any) {
|
||||
|
||||
export async function saveStream() {
|
||||
const streamId = (document.getElementById('stream-id') as HTMLInputElement).value;
|
||||
if (streamModal.closeIfPristine(streamId)) return;
|
||||
|
||||
const name = (document.getElementById('stream-name') as HTMLInputElement).value.trim();
|
||||
const streamType = (document.getElementById('stream-type') as HTMLSelectElement).value;
|
||||
const description = (document.getElementById('stream-description') as HTMLInputElement).value.trim();
|
||||
@@ -2050,6 +2052,8 @@ export async function editPPTemplate(templateId: any) {
|
||||
|
||||
export async function savePPTemplate() {
|
||||
const templateId = (document.getElementById('pp-template-id') as HTMLInputElement).value;
|
||||
if (ppTemplateModal.closeIfPristine(templateId)) return;
|
||||
|
||||
const name = (document.getElementById('pp-template-name') as HTMLInputElement).value.trim();
|
||||
const description = (document.getElementById('pp-template-description') as HTMLInputElement).value.trim();
|
||||
const errorEl = document.getElementById('pp-template-error')!;
|
||||
@@ -2265,6 +2269,8 @@ export async function editCSPT(templateId: any) {
|
||||
|
||||
export async function saveCSPT() {
|
||||
const templateId = (document.getElementById('cspt-id') as HTMLInputElement).value;
|
||||
if (csptModal.closeIfPristine(templateId)) return;
|
||||
|
||||
const name = (document.getElementById('cspt-name') as HTMLInputElement).value.trim();
|
||||
const description = (document.getElementById('cspt-description') as HTMLInputElement).value.trim();
|
||||
const errorEl = document.getElementById('cspt-error')!;
|
||||
|
||||
@@ -110,6 +110,8 @@ export async function closeSyncClockModal(): Promise<void> {
|
||||
|
||||
export async function saveSyncClock(): Promise<void> {
|
||||
const id = (document.getElementById('sync-clock-id') as HTMLInputElement).value;
|
||||
if (syncClockModal.closeIfPristine(id)) return;
|
||||
|
||||
const name = (document.getElementById('sync-clock-name') as HTMLInputElement).value.trim();
|
||||
const speed = parseFloat((document.getElementById('sync-clock-speed') as HTMLInputElement).value);
|
||||
const description = (document.getElementById('sync-clock-description') as HTMLInputElement).value.trim() || null;
|
||||
|
||||
@@ -496,6 +496,8 @@ export function forceCloseTargetEditorModal() {
|
||||
|
||||
export async function saveTargetEditor() {
|
||||
const targetId = (document.getElementById('target-editor-id') as HTMLInputElement).value;
|
||||
if (targetEditorModal.closeIfPristine(targetId)) return;
|
||||
|
||||
const name = (document.getElementById('target-editor-name') as HTMLInputElement).value.trim();
|
||||
const deviceId = (document.getElementById('target-editor-device') as HTMLSelectElement).value;
|
||||
const standbyInterval = parseFloat((document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
_cachedHASources, _cachedColorStripSources, gradientsCache, GradientEntity,
|
||||
_cachedGameIntegrations, _cachedGameAdapters, gameIntegrationsCache, gameAdaptersCache,
|
||||
_cachedSyncClocks, syncClocksCache,
|
||||
_cachedHTTPEndpoints, httpEndpointsCache,
|
||||
getHAEntityFriendlyName, setHAEntityNames,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
@@ -74,6 +75,7 @@ let _vsGradientEasingIconSelect: IconSelect | null = null;
|
||||
let _vsBehaviorIconSelect: IconSelect | null = null;
|
||||
let _vsMetricIconSelect: IconSelect | null = null;
|
||||
let _vsAnimColorClockEntitySelect: EntitySelect | null = null;
|
||||
let _vsHTTPEndpointEntitySelect: EntitySelect | null = null;
|
||||
let _vsTagsInput: TagInput | null = null;
|
||||
|
||||
class ValueSourceModal extends Modal {
|
||||
@@ -92,6 +94,7 @@ class ValueSourceModal extends Modal {
|
||||
if (_vsMetricIconSelect) { _vsMetricIconSelect.destroy(); _vsMetricIconSelect = null; }
|
||||
if (_vsAnimColorClockEntitySelect) { _vsAnimColorClockEntitySelect.destroy(); _vsAnimColorClockEntitySelect = null; }
|
||||
if (_vsGameIntegrationEntitySelect) { _vsGameIntegrationEntitySelect.destroy(); _vsGameIntegrationEntitySelect = null; }
|
||||
if (_vsHTTPEndpointEntitySelect) { _vsHTTPEndpointEntitySelect.destroy(); _vsHTTPEndpointEntitySelect = null; }
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
@@ -136,6 +139,13 @@ class ValueSourceModal extends Modal {
|
||||
sensorLabel: (document.getElementById('value-source-sensor-label') as HTMLInputElement).value,
|
||||
pollInterval: (document.getElementById('value-source-poll-interval') as HTMLInputElement).value,
|
||||
sysmetricSmoothing: (document.getElementById('value-source-sysmetric-smoothing') as HTMLInputElement).value,
|
||||
// HTTP value source
|
||||
httpEndpoint: (document.getElementById('value-source-http-endpoint') as HTMLSelectElement | null)?.value || '',
|
||||
httpJsonPath: (document.getElementById('value-source-http-json-path') as HTMLInputElement | null)?.value || '',
|
||||
httpInterval: (document.getElementById('value-source-http-interval') as HTMLInputElement | null)?.value || '',
|
||||
httpMin: (document.getElementById('value-source-http-min') as HTMLInputElement | null)?.value || '',
|
||||
httpMax: (document.getElementById('value-source-http-max') as HTMLInputElement | null)?.value || '',
|
||||
httpSmoothing: (document.getElementById('value-source-http-smoothing') as HTMLInputElement | null)?.value || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -168,13 +178,17 @@ function _autoGenerateVSName() {
|
||||
} else if (type === 'game_event') {
|
||||
const eventType = (document.getElementById('value-source-game-event-type') as HTMLSelectElement)?.value;
|
||||
if (eventType) detail = eventType;
|
||||
} else if (type === 'http') {
|
||||
const sel = document.getElementById('value-source-http-endpoint') as HTMLSelectElement | null;
|
||||
const name = sel?.selectedOptions[0]?.textContent?.trim();
|
||||
if (name) detail = name;
|
||||
}
|
||||
(document.getElementById('value-source-name') as HTMLInputElement).value = detail ? `${typeLabel} · ${detail}` : typeLabel;
|
||||
}
|
||||
|
||||
/* ── Icon-grid type selector ──────────────────────────────────── */
|
||||
|
||||
const VS_FLOAT_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight', 'ha_entity', 'system_metrics', 'game_event'];
|
||||
const VS_FLOAT_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight', 'ha_entity', 'system_metrics', 'game_event', 'http'];
|
||||
const VS_COLOR_TYPE_KEYS = ['static_color', 'animated_color', 'adaptive_time_color', 'gradient_map', 'css_extract'];
|
||||
const VS_TYPE_KEYS = [...VS_FLOAT_TYPE_KEYS, ...VS_COLOR_TYPE_KEYS];
|
||||
|
||||
@@ -446,6 +460,31 @@ function _ensureVSTypeIconSelect() {
|
||||
_vsTypeIconSelect = new IconSelect({ target: sel, items: _buildVSTypeItems(), columns: 2 } as any);
|
||||
}
|
||||
|
||||
/* ── HTTP endpoint picker (EntitySelect over httpEndpointsCache) ── */
|
||||
|
||||
function _populateVSHTTPEndpointDropdown(selectedId: string = '') {
|
||||
const sel = document.getElementById('value-source-http-endpoint') as HTMLSelectElement;
|
||||
if (!sel) return;
|
||||
const endpoints = _cachedHTTPEndpoints || [];
|
||||
const prev = selectedId || sel.value;
|
||||
sel.innerHTML = `<option value="">—</option>` +
|
||||
endpoints.map(e => `<option value="${e.id}"${e.id === prev ? ' selected' : ''}>${escapeHtml(e.name)}</option>`).join('');
|
||||
sel.value = prev || '';
|
||||
|
||||
if (_vsHTTPEndpointEntitySelect) _vsHTTPEndpointEntitySelect.destroy();
|
||||
_vsHTTPEndpointEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => (_cachedHTTPEndpoints || []).map(e => ({
|
||||
value: e.id,
|
||||
label: e.name,
|
||||
icon: `<svg class="icon" viewBox="0 0 24 24">${P.globe}</svg>`,
|
||||
desc: `${e.method} ${e.url}`,
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
onChange: () => _autoGenerateVSName(),
|
||||
} as any);
|
||||
}
|
||||
|
||||
// ── Modal ─────────────────────────────────────────────────────
|
||||
|
||||
export async function showValueSourceModal(editData: any, presetType: any = null) {
|
||||
@@ -586,6 +625,14 @@ export async function showValueSourceModal(editData: any, presetType: any = null
|
||||
_setSlider('value-source-ge-smoothing', editData.smoothing ?? 0);
|
||||
_setSlider('value-source-ge-default', editData.default_value ?? 0.5);
|
||||
_setSlider('value-source-ge-timeout', editData.timeout ?? 5.0);
|
||||
} else if (editData.source_type === 'http') {
|
||||
await httpEndpointsCache.fetch();
|
||||
_populateVSHTTPEndpointDropdown(editData.http_endpoint_id || '');
|
||||
(document.getElementById('value-source-http-json-path') as HTMLInputElement).value = editData.json_path || '';
|
||||
(document.getElementById('value-source-http-interval') as HTMLInputElement).value = String(editData.interval_s ?? 60);
|
||||
(document.getElementById('value-source-http-min') as HTMLInputElement).value = String(editData.min_value ?? 0);
|
||||
(document.getElementById('value-source-http-max') as HTMLInputElement).value = String(editData.max_value ?? 100);
|
||||
_setSlider('value-source-http-smoothing', editData.smoothing ?? 0);
|
||||
}
|
||||
} else {
|
||||
(document.getElementById('value-source-name') as HTMLInputElement).value = '';
|
||||
@@ -650,6 +697,16 @@ export async function showValueSourceModal(editData: any, presetType: any = null
|
||||
(document.getElementById('value-source-sensor-label') as HTMLInputElement).value = '';
|
||||
_setSlider('value-source-poll-interval', 1.0);
|
||||
_setSlider('value-source-sysmetric-smoothing', 0);
|
||||
// HTTP value source defaults
|
||||
const httpJsonPath = document.getElementById('value-source-http-json-path') as HTMLInputElement | null;
|
||||
if (httpJsonPath) httpJsonPath.value = '';
|
||||
const httpInterval = document.getElementById('value-source-http-interval') as HTMLInputElement | null;
|
||||
if (httpInterval) httpInterval.value = '60';
|
||||
const httpMin = document.getElementById('value-source-http-min') as HTMLInputElement | null;
|
||||
if (httpMin) httpMin.value = '0';
|
||||
const httpMax = document.getElementById('value-source-http-max') as HTMLInputElement | null;
|
||||
if (httpMax) httpMax.value = '100';
|
||||
_setSlider('value-source-http-smoothing', 0);
|
||||
_autoGenerateVSName();
|
||||
}
|
||||
|
||||
@@ -699,6 +756,13 @@ export function onValueSourceTypeChange() {
|
||||
if (type === 'game_event') {
|
||||
_populateVSGameIntegrationDropdown('');
|
||||
}
|
||||
const httpSec = document.getElementById('value-source-http-section') as HTMLElement | null;
|
||||
if (httpSec) httpSec.style.display = type === 'http' ? '' : 'none';
|
||||
if (type === 'http') {
|
||||
// Refresh endpoint list lazily — value-source modal can be opened
|
||||
// before the integrations tab has been visited.
|
||||
httpEndpointsCache.fetch().then(() => _populateVSHTTPEndpointDropdown(''));
|
||||
}
|
||||
(document.getElementById('value-source-adaptive-range-section') as HTMLElement).style.display =
|
||||
(type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : 'none';
|
||||
|
||||
@@ -755,6 +819,8 @@ function _syncDaylightVSSpeedVisibility() {
|
||||
|
||||
export async function saveValueSource() {
|
||||
const id = (document.getElementById('value-source-id') as HTMLInputElement).value;
|
||||
if (valueSourceModal.closeIfPristine(id)) return;
|
||||
|
||||
const name = (document.getElementById('value-source-name') as HTMLInputElement).value.trim();
|
||||
const sourceType = (document.getElementById('value-source-type') as HTMLSelectElement).value;
|
||||
const description = (document.getElementById('value-source-description') as HTMLInputElement).value.trim() || null;
|
||||
@@ -879,6 +945,23 @@ export async function saveValueSource() {
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
} else if (sourceType === 'http') {
|
||||
payload.http_endpoint_id = (document.getElementById('value-source-http-endpoint') as HTMLSelectElement).value;
|
||||
payload.json_path = (document.getElementById('value-source-http-json-path') as HTMLInputElement).value.trim();
|
||||
payload.interval_s = parseInt((document.getElementById('value-source-http-interval') as HTMLInputElement).value, 10) || 60;
|
||||
payload.min_value = parseFloat((document.getElementById('value-source-http-min') as HTMLInputElement).value) || 0;
|
||||
payload.max_value = parseFloat((document.getElementById('value-source-http-max') as HTMLInputElement).value) || 100;
|
||||
payload.smoothing = parseFloat((document.getElementById('value-source-http-smoothing') as HTMLInputElement).value) || 0;
|
||||
if (!payload.http_endpoint_id) {
|
||||
errorEl.textContent = t('value_source.http.endpoint_required');
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
if (payload.interval_s < 1) {
|
||||
errorEl.textContent = t('value_source.http.interval_invalid');
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -146,6 +146,8 @@ export async function closeWeatherSourceModal(): Promise<void> {
|
||||
|
||||
export async function saveWeatherSource(): Promise<void> {
|
||||
const id = (document.getElementById('weather-source-id') as HTMLInputElement).value;
|
||||
if (weatherSourceModal.closeIfPristine(id)) return;
|
||||
|
||||
const name = (document.getElementById('weather-source-name') as HTMLInputElement).value.trim();
|
||||
const provider = (document.getElementById('weather-source-provider') as HTMLSelectElement).value;
|
||||
const latitude = parseFloat((document.getElementById('weather-source-latitude') as HTMLInputElement).value) || 50.0;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { logError } from '../core/log.ts';
|
||||
import { safeJsonParse } from '../core/storage.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm, formatUptime } from '../core/ui.ts';
|
||||
@@ -161,25 +162,35 @@ export function addZ2MLightMapping(data: any = null): void {
|
||||
|
||||
// Per-source-kind row layout: CSS shows LED ranges; color_vs hides them
|
||||
// and promotes the brightness scale to a single inline field.
|
||||
// Numeric fields are coerced via ``Number()`` so a hostile/stale JSON
|
||||
// mapping (where a number got serialised as a string with markup) can't
|
||||
// smuggle HTML into the attribute context. ``Number.isFinite`` filters
|
||||
// ``NaN`` so the fallback always renders a sane default.
|
||||
const brightnessVal = Number.isFinite(Number(data?.brightness_scale))
|
||||
? Number(data?.brightness_scale)
|
||||
: 1.0;
|
||||
const ledStartVal = Number.isFinite(Number(data?.led_start)) ? Number(data?.led_start) : 0;
|
||||
const ledEndVal = Number.isFinite(Number(data?.led_end)) ? Number(data?.led_end) : -1;
|
||||
|
||||
const rangeBlock = _editorSourceKind === 'color_vs'
|
||||
? `<div class="ha-mapping-range-row">
|
||||
<div>
|
||||
<label>${t('z2m_light.mapping.brightness')}</label>
|
||||
<input type="number" class="z2m-mapping-brightness" value="${data?.brightness_scale ?? 1.0}" min="0" max="1" step="0.1">
|
||||
<input type="number" class="z2m-mapping-brightness" value="${brightnessVal}" min="0" max="1" step="0.1">
|
||||
</div>
|
||||
</div>`
|
||||
: `<div class="ha-mapping-range-row">
|
||||
<div>
|
||||
<label>${t('z2m_light.mapping.led_start')}</label>
|
||||
<input type="number" class="z2m-mapping-led-start" value="${data?.led_start ?? 0}" min="0" step="1">
|
||||
<input type="number" class="z2m-mapping-led-start" value="${ledStartVal}" min="0" step="1">
|
||||
</div>
|
||||
<div>
|
||||
<label>${t('z2m_light.mapping.led_end')}</label>
|
||||
<input type="number" class="z2m-mapping-led-end" value="${data?.led_end ?? -1}" min="-1" step="1">
|
||||
<input type="number" class="z2m-mapping-led-end" value="${ledEndVal}" min="-1" step="1">
|
||||
</div>
|
||||
<div>
|
||||
<label>${t('z2m_light.mapping.brightness')}</label>
|
||||
<input type="number" class="z2m-mapping-brightness" value="${data?.brightness_scale ?? 1.0}" min="0" max="1" step="0.1">
|
||||
<input type="number" class="z2m-mapping-brightness" value="${brightnessVal}" min="0" max="1" step="0.1">
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
@@ -224,7 +235,10 @@ export function removeZ2MLightMapping(btn: HTMLElement): void {
|
||||
function _rerenderMappingsForMode(): void {
|
||||
const list = document.getElementById('z2m-light-mappings-list');
|
||||
if (!list) return;
|
||||
const snapshot = JSON.parse(_getMappingsJSON());
|
||||
// Guarded JSON parse — _getMappingsJSON reads live DOM values so a
|
||||
// partially-rendered row (or hand-edited input) used to throw and
|
||||
// wipe the editor mid-toggle. ``safeJsonParse`` falls back to [].
|
||||
const snapshot = safeJsonParse<unknown[]>(_getMappingsJSON(), []);
|
||||
list.innerHTML = '';
|
||||
snapshot.forEach((m: any) => addZ2MLightMapping(m));
|
||||
_setMappingsModeHint();
|
||||
@@ -390,6 +404,8 @@ export async function closeZ2MLightEditor(): Promise<void> {
|
||||
|
||||
export async function saveZ2MLightEditor(): Promise<void> {
|
||||
const targetId = (document.getElementById('z2m-light-editor-id') as HTMLInputElement).value;
|
||||
if (z2mLightEditorModal.closeIfPristine(targetId)) return;
|
||||
|
||||
const name = (document.getElementById('z2m-light-editor-name') as HTMLInputElement).value.trim();
|
||||
const mqttSourceId = (document.getElementById('z2m-light-editor-mqtt-source') as HTMLSelectElement).value;
|
||||
const baseTopic = (document.getElementById('z2m-light-editor-base-topic') as HTMLInputElement).value.trim() || 'zigbee2mqtt';
|
||||
@@ -420,7 +436,8 @@ export async function saveZ2MLightEditor(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const mappings = JSON.parse(_getMappingsJSON()).filter((m: any) => m.friendly_name);
|
||||
const mappings = safeJsonParse<Array<Record<string, unknown>>>(_getMappingsJSON(), [])
|
||||
.filter((m) => m && typeof m === 'object' && 'friendly_name' in m && (m as { friendly_name: unknown }).friendly_name);
|
||||
if (mappings.length === 0) {
|
||||
z2mLightEditorModal.showError(t('z2m_light.error.mapping_required') || 'At least one bulb mapping is required');
|
||||
return;
|
||||
@@ -713,10 +730,8 @@ export function connectZ2MLightWS(targetId: string): void {
|
||||
openAuthedWs(url).then((ws) => {
|
||||
_z2mLightWS[targetId] = ws;
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.data);
|
||||
if (data.type === 'colors_update') _updateSwatchColors(targetId, data.colors);
|
||||
} catch (err) { logError('z2m-light-targets.ws.message', err); }
|
||||
const data = safeJsonParse<{ type?: string; colors?: unknown }>(ev.data, {});
|
||||
if (data.type === 'colors_update') _updateSwatchColors(targetId, data.colors as never);
|
||||
};
|
||||
ws.onclose = () => { delete _z2mLightWS[targetId]; };
|
||||
ws.onerror = () => { delete _z2mLightWS[targetId]; };
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Ambient declarations for the project's `window` globals.
|
||||
*
|
||||
* Several legacy modules attach helpers onto `window` so they can be
|
||||
* called from HTML ``onclick`` attributes / global tab-registry lookups.
|
||||
* Without these declarations every callsite used ``(window as any).foo``,
|
||||
* which silently erases type errors at the call boundary. Declaring the
|
||||
* known fields here lets `tsc --noEmit` flag real typos while keeping
|
||||
* the call sites readable.
|
||||
*
|
||||
* The list is the minimal subset that covers the ``(window as any).<name>``
|
||||
* sites flagged by the cross-file audit. Anything indexed by dynamic
|
||||
* string ($name = `${kind}Foo`) still legitimately needs an indexed
|
||||
* access — those cases keep their narrow casts.
|
||||
*/
|
||||
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
// Auth / setup gates (set from core/api.ts during boot)
|
||||
_authRequired?: boolean;
|
||||
_setupRequired?: boolean;
|
||||
_setupModalOpen?: boolean;
|
||||
|
||||
// i18n shim — present once core/i18n.ts initialises.
|
||||
__t?: (key: string) => string;
|
||||
|
||||
// UI helpers exposed for inline onclick handlers in templates.
|
||||
applyAccentColor?: () => void;
|
||||
hideSetupRequiredModal?: () => void;
|
||||
configureKCRegions?: (sourceId: string) => void;
|
||||
removeZ2MLightMapping?: (btn: HTMLElement) => void;
|
||||
|
||||
// Feature reloaders. They are called from cross-feature code that
|
||||
// doesn't want a hard module import (to avoid cycles).
|
||||
loadAutomations?: () => Promise<void> | void;
|
||||
loadPictureSources?: () => Promise<void> | void;
|
||||
showPatternTemplateEditor?: (...args: unknown[]) => unknown;
|
||||
|
||||
// Internal helpers attached by features for inline-call reuse.
|
||||
_autoGenerateAutomationName?: () => void;
|
||||
_openKCRegionEditor?: (sourceId: string) => void;
|
||||
|
||||
// HTTP endpoint editor — used by the integrations tab and
|
||||
// automation rule editor.
|
||||
showHTTPEndpointModal?: (editData?: unknown) => Promise<void>;
|
||||
closeHTTPEndpointModal?: () => Promise<void>;
|
||||
saveHTTPEndpoint?: () => Promise<void>;
|
||||
editHTTPEndpoint?: (id: string) => Promise<void>;
|
||||
cloneHTTPEndpoint?: (id: string) => Promise<void>;
|
||||
deleteHTTPEndpoint?: (id: string) => Promise<void>;
|
||||
testHTTPEndpoint?: () => Promise<void>;
|
||||
addHTTPEndpointHeader?: () => void;
|
||||
toggleHTTPEndpointTokenVisibility?: () => void;
|
||||
|
||||
// HA / MQTT source editors — declared so app.ts assignments
|
||||
// satisfy strict mode. (Not exhaustive; only what http-endpoints
|
||||
// wires up.)
|
||||
showHASourceModal?: (...args: unknown[]) => unknown;
|
||||
closeHASourceModal?: () => Promise<void>;
|
||||
saveHASource?: () => Promise<void>;
|
||||
editHASource?: (id: string) => Promise<void>;
|
||||
cloneHASource?: (id: string) => Promise<void>;
|
||||
deleteHASource?: (id: string) => Promise<void>;
|
||||
testHASource?: () => Promise<void>;
|
||||
}
|
||||
}
|
||||
@@ -3,45 +3,26 @@
|
||||
*
|
||||
* These mirror the JSON shapes returned by the REST API. Field names use
|
||||
* snake_case to match the JSON payloads — no camelCase transformation is done.
|
||||
*
|
||||
* Bindable primitives have been extracted into ``types/bindable.ts`` and
|
||||
* are re-exported here so existing ``import { ... } from '../types.ts'``
|
||||
* call sites keep working. The intention is for further entity-shape
|
||||
* groups (devices, sources, integrations, …) to follow the same pattern
|
||||
* in subsequent passes — see audit finding H6.
|
||||
*/
|
||||
|
||||
// ── Bindable Float ───────────────────────────────────────────
|
||||
// A scalar that is either a static value (plain number) or bound to a value source (dict).
|
||||
// ── Bindable Primitives ─────────────────────────────────────
|
||||
export type { BindableFloat, BindableColor } from './types/bindable.ts';
|
||||
export {
|
||||
bindableValue,
|
||||
bindableSourceId,
|
||||
bindableColor,
|
||||
bindableColorSourceId,
|
||||
} from './types/bindable.ts';
|
||||
|
||||
export type BindableFloat = number | { value: number; source_id: string };
|
||||
|
||||
/** Extract the static value from a BindableFloat. */
|
||||
export function bindableValue(b: BindableFloat | undefined, fallback: number): number {
|
||||
if (b === undefined || b === null) return fallback;
|
||||
if (typeof b === 'number') return b;
|
||||
return b.value ?? fallback;
|
||||
}
|
||||
|
||||
/** Extract the source_id from a BindableFloat (empty string = not bound). */
|
||||
export function bindableSourceId(b: BindableFloat | undefined): string {
|
||||
if (b === undefined || b === null) return '';
|
||||
if (typeof b === 'number') return '';
|
||||
return b.source_id ?? '';
|
||||
}
|
||||
|
||||
// ── Bindable Color ──────────────────────────────────────────
|
||||
// An RGB color that is either static ([R,G,B] array) or bound to a color value source.
|
||||
|
||||
export type BindableColor = number[] | { color: number[]; source_id: string };
|
||||
|
||||
/** Extract the static [R,G,B] from a BindableColor. */
|
||||
export function bindableColor(b: BindableColor | undefined, fallback: number[]): number[] {
|
||||
if (b === undefined || b === null) return fallback;
|
||||
if (Array.isArray(b)) return b;
|
||||
return b.color ?? fallback;
|
||||
}
|
||||
|
||||
/** Extract the source_id from a BindableColor (empty string = not bound). */
|
||||
export function bindableColorSourceId(b: BindableColor | undefined): string {
|
||||
if (b === undefined || b === null) return '';
|
||||
if (Array.isArray(b)) return '';
|
||||
return b.source_id ?? '';
|
||||
}
|
||||
// Local aliases used by the entity interfaces below so TypeScript can
|
||||
// resolve them without an extra import at every reference site.
|
||||
import type { BindableFloat, BindableColor } from './types/bindable.ts';
|
||||
|
||||
// ── Device ────────────────────────────────────────────────────
|
||||
|
||||
@@ -186,7 +167,7 @@ export type OutputTarget = LedOutputTarget | HALightOutputTarget | Z2MLightOutpu
|
||||
// ── Color Strip Source ────────────────────────────────────────
|
||||
|
||||
export type CSSSourceType =
|
||||
| 'picture' | 'picture_advanced' | 'static' | 'gradient'
|
||||
| 'picture' | 'picture_advanced' | 'single_color' | 'gradient'
|
||||
| 'effect' | 'composite' | 'mapped'
|
||||
| 'audio' | 'api_input' | 'notification' | 'daylight'
|
||||
| 'candlelight' | 'processed' | 'weather' | 'key_colors'
|
||||
@@ -379,7 +360,7 @@ export type ValueSourceType =
|
||||
| 'adaptive_time' | 'adaptive_scene' | 'daylight'
|
||||
| 'static_color' | 'animated_color' | 'adaptive_time_color'
|
||||
| 'ha_entity' | 'gradient_map' | 'css_extract'
|
||||
| 'system_metrics' | 'game_event';
|
||||
| 'system_metrics' | 'game_event' | 'http';
|
||||
|
||||
export interface SchedulePoint {
|
||||
time: string;
|
||||
@@ -534,6 +515,17 @@ export interface GameEventValueSource extends ValueSourceBase {
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
export interface HTTPValueSource extends ValueSourceBase {
|
||||
source_type: 'http';
|
||||
return_type: 'float';
|
||||
http_endpoint_id: string;
|
||||
json_path: string;
|
||||
interval_s: number;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
smoothing: number;
|
||||
}
|
||||
|
||||
export type ValueSource =
|
||||
| StaticValueSource
|
||||
| AnimatedValueSource
|
||||
@@ -548,7 +540,8 @@ export type ValueSource =
|
||||
| GradientMapValueSource
|
||||
| CSSExtractValueSource
|
||||
| SystemMetricsValueSource
|
||||
| GameEventValueSource;
|
||||
| GameEventValueSource
|
||||
| HTTPValueSource;
|
||||
|
||||
// ── Audio Source ───────────────────────────────────────────────
|
||||
|
||||
@@ -772,6 +765,68 @@ export interface MQTTStatusResponse {
|
||||
connected_count: number;
|
||||
}
|
||||
|
||||
// ── HTTP Endpoint ────────────────────────────────────────────
|
||||
//
|
||||
// A connection definition only (URL + auth + headers + timeout).
|
||||
// No polling cadence is configured on the endpoint itself —
|
||||
// HTTPValueSource owns interval_s and references the endpoint.
|
||||
|
||||
export type HTTPMethod = 'GET' | 'HEAD';
|
||||
|
||||
export interface HTTPEndpoint {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
method: HTTPMethod;
|
||||
/** Server NEVER returns the token; this flag indicates one is stored. */
|
||||
auth_token_set: boolean;
|
||||
headers: Record<string, string>;
|
||||
timeout_s: number;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
icon?: string;
|
||||
icon_color?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface HTTPEndpointListResponse {
|
||||
endpoints: HTTPEndpoint[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Wire payload for `POST /http/endpoints` / `PUT /http/endpoints/{id}`.
|
||||
* All fields optional — the route validates required-on-create separately. */
|
||||
export interface HTTPEndpointWritePayload {
|
||||
name?: string;
|
||||
url?: string;
|
||||
method?: HTTPMethod;
|
||||
/** Plaintext token. PUT distinguishes None=keep / ""=clear; omit the field to keep. */
|
||||
auth_token?: string;
|
||||
headers?: Record<string, string>;
|
||||
timeout_s?: number;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
icon?: string;
|
||||
icon_color?: string;
|
||||
}
|
||||
|
||||
export interface HTTPTestRequest {
|
||||
url: string;
|
||||
method: HTTPMethod;
|
||||
auth_token: string;
|
||||
headers: Record<string, string>;
|
||||
timeout_s: number;
|
||||
}
|
||||
|
||||
export interface HTTPTestResponse {
|
||||
success: boolean;
|
||||
status_code?: number;
|
||||
body_preview?: string;
|
||||
body_json?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ── Asset ────────────────────────────────────────────────────
|
||||
|
||||
export interface Asset {
|
||||
@@ -799,7 +854,12 @@ export interface AssetListResponse {
|
||||
|
||||
export type RuleType =
|
||||
| 'application' | 'time_of_day' | 'system_idle'
|
||||
| 'display_state' | 'mqtt' | 'webhook' | 'startup';
|
||||
| 'display_state' | 'mqtt' | 'webhook' | 'startup'
|
||||
| 'home_assistant' | 'http_poll';
|
||||
|
||||
export type HTTPPollOperator =
|
||||
| 'equals' | 'not_equals' | 'contains' | 'regex'
|
||||
| 'gt' | 'lt' | 'exists';
|
||||
|
||||
export interface AutomationRule {
|
||||
rule_type: RuleType;
|
||||
@@ -814,6 +874,13 @@ export interface AutomationRule {
|
||||
payload?: string;
|
||||
match_mode?: string;
|
||||
token?: string;
|
||||
/** home_assistant rule */
|
||||
ha_source_id?: string;
|
||||
entity_id?: string;
|
||||
/** http_poll rule — references an HTTPValueSource. */
|
||||
value_source_id?: string;
|
||||
operator?: HTTPPollOperator;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface Automation {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Bindable scalar / colour primitives.
|
||||
*
|
||||
* A "bindable" value is either a plain literal or a reference to a value
|
||||
* source (``{value | color, source_id}``). These types and the four
|
||||
* accessor helpers are imported widely — over 30 frontend modules read
|
||||
* them through the ``types.ts`` barrel — so they live in their own file
|
||||
* to keep the entity-shape files free of helper-function noise.
|
||||
*/
|
||||
|
||||
// ── Bindable Float ───────────────────────────────────────────
|
||||
// A scalar that is either a static value (plain number) or bound to a value source (dict).
|
||||
|
||||
export type BindableFloat = number | { value: number; source_id: string };
|
||||
|
||||
/** Extract the static value from a BindableFloat. */
|
||||
export function bindableValue(b: BindableFloat | undefined, fallback: number): number {
|
||||
if (b === undefined || b === null) return fallback;
|
||||
if (typeof b === 'number') return b;
|
||||
return b.value ?? fallback;
|
||||
}
|
||||
|
||||
/** Extract the source_id from a BindableFloat (empty string = not bound). */
|
||||
export function bindableSourceId(b: BindableFloat | undefined): string {
|
||||
if (b === undefined || b === null) return '';
|
||||
if (typeof b === 'number') return '';
|
||||
return b.source_id ?? '';
|
||||
}
|
||||
|
||||
// ── Bindable Color ──────────────────────────────────────────
|
||||
// An RGB color that is either static ([R,G,B] array) or bound to a color value source.
|
||||
|
||||
export type BindableColor = number[] | { color: number[]; source_id: string };
|
||||
|
||||
/** Extract the static [R,G,B] from a BindableColor. */
|
||||
export function bindableColor(b: BindableColor | undefined, fallback: number[]): number[] {
|
||||
if (b === undefined || b === null) return fallback;
|
||||
if (Array.isArray(b)) return b;
|
||||
return b.color ?? fallback;
|
||||
}
|
||||
|
||||
/** Extract the source_id from a BindableColor (empty string = not bound). */
|
||||
export function bindableColorSourceId(b: BindableColor | undefined): string {
|
||||
if (b === undefined || b === null) return '';
|
||||
if (Array.isArray(b)) return '';
|
||||
return b.source_id ?? '';
|
||||
}
|
||||
@@ -1352,7 +1352,7 @@
|
||||
"color_strip.test_device.hint": "Select a device to send test pixels to when clicking edge toggles",
|
||||
"color_strip.leds": "LED count",
|
||||
"color_strip.led_count": "LED Count:",
|
||||
"color_strip.led_count.hint": "Total number of LEDs on the physical strip. For screen sources: 0 = auto from calibration (extra LEDs not mapped to edges will be black). For static color: set to match your device LED count.",
|
||||
"color_strip.led_count.hint": "Total number of LEDs on the physical strip. For screen sources: 0 = auto from calibration (extra LEDs not mapped to edges will be black). For single color: set to match your device LED count.",
|
||||
"color_strip.created": "Color strip source created",
|
||||
"color_strip.updated": "Color strip source updated",
|
||||
"color_strip.deleted": "Color strip source deleted",
|
||||
@@ -1360,17 +1360,17 @@
|
||||
"color_strip.delete.referenced": "Cannot delete: this source is in use by a target",
|
||||
"color_strip.error.name_required": "Please enter a name",
|
||||
"color_strip.type": "Type:",
|
||||
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors. Composite stacks multiple sources as blended layers. Audio Reactive drives LEDs from real-time audio input. API Input receives raw LED colors from external clients via REST or WebSocket.",
|
||||
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Single Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors. Composite stacks multiple sources as blended layers. Audio Reactive drives LEDs from real-time audio input. API Input receives raw LED colors from external clients via REST or WebSocket.",
|
||||
"color_strip.type.picture": "Picture Source",
|
||||
"color_strip.type.picture.desc": "Colors from screen capture",
|
||||
"color_strip.type.picture_advanced": "Multi-Monitor",
|
||||
"color_strip.type.picture_advanced.desc": "Line-based calibration across monitors",
|
||||
"color_strip.type.static": "Static Color",
|
||||
"color_strip.type.static.desc": "Single solid color fill",
|
||||
"color_strip.type.single_color": "Single Color",
|
||||
"color_strip.type.single_color.desc": "Single solid color fill",
|
||||
"color_strip.type.gradient": "Gradient",
|
||||
"color_strip.type.gradient.desc": "Smooth color transition across LEDs",
|
||||
"color_strip.static_color": "Color:",
|
||||
"color_strip.static_color.hint": "The solid color that will be sent to all LEDs on the strip.",
|
||||
"color_strip.single_color": "Color:",
|
||||
"color_strip.single_color.hint": "The solid color that will be sent to all LEDs on the strip.",
|
||||
"color_strip.gradient.preview": "Gradient:",
|
||||
"color_strip.gradient.preview.hint": "Visual preview. Click the marker track below to add a stop. Drag markers to reposition.",
|
||||
"color_strip.gradient.easing": "Easing:",
|
||||
@@ -2961,5 +2961,89 @@
|
||||
"pairing.success": "Paired successfully",
|
||||
"pairing.not_ready": "Device didn't respond. Press the pairing button on your device, then try again.",
|
||||
"pairing.failed": "Pairing failed: {detail}",
|
||||
"pairing.failed_prefix": "Pairing failed:"
|
||||
"pairing.failed_prefix": "Pairing failed:",
|
||||
"streams.group.http": "HTTP",
|
||||
"http_endpoint.group.title": "HTTP Endpoints",
|
||||
"http_endpoint.add": "Add HTTP Endpoint",
|
||||
"http_endpoint.edit": "Edit HTTP Endpoint",
|
||||
"http_endpoint.section.request": "Request",
|
||||
"http_endpoint.section.headers": "Headers",
|
||||
"http_endpoint.name": "Name:",
|
||||
"http_endpoint.name.placeholder": "Plex now-playing",
|
||||
"http_endpoint.name.hint": "A descriptive name for this endpoint",
|
||||
"http_endpoint.url": "URL:",
|
||||
"http_endpoint.url.hint": "Full http(s) URL to poll. Local addresses are allowed.",
|
||||
"http_endpoint.method": "Method:",
|
||||
"http_endpoint.method.get.desc": "Fetch the response body.",
|
||||
"http_endpoint.method.head.desc": "Status code only — no body. Great for liveness probes.",
|
||||
"http_endpoint.auth_token": "Auth Token (optional):",
|
||||
"http_endpoint.auth_token.hint": "Sent as 'Authorization: Bearer <token>'. Add a custom Authorization header to override.",
|
||||
"http_endpoint.auth_token.edit_hint": "Leave blank to keep the current token",
|
||||
"http_endpoint.auth_token.reveal": "Show / hide token",
|
||||
"http_endpoint.auth.set": "Auth",
|
||||
"http_endpoint.timeout": "Timeout (s):",
|
||||
"http_endpoint.timeout.hint": "Maximum seconds to wait for a single request.",
|
||||
"http_endpoint.test": "Test request",
|
||||
"http_endpoint.test.pending": "Testing…",
|
||||
"http_endpoint.test.success": "OK",
|
||||
"http_endpoint.test.failed": "Failed",
|
||||
"http_endpoint.test.body.json": "JSON body",
|
||||
"http_endpoint.test.body.text": "Response body",
|
||||
"http_endpoint.headers": "Custom Headers:",
|
||||
"http_endpoint.headers.hint": "Optional request headers (e.g. X-API-Key, Accept).",
|
||||
"http_endpoint.headers.add": "Add header",
|
||||
"http_endpoint.headers.empty": "No custom headers — defaults will be sent.",
|
||||
"http_endpoint.headers.name_placeholder": "Header name",
|
||||
"http_endpoint.headers.value_placeholder": "Header value",
|
||||
"http_endpoint.headers.count": "{n} headers",
|
||||
"http_endpoint.description": "Description (optional):",
|
||||
"http_endpoint.created": "HTTP endpoint created",
|
||||
"http_endpoint.updated": "HTTP endpoint updated",
|
||||
"http_endpoint.deleted": "HTTP endpoint deleted",
|
||||
"http_endpoint.delete.confirm": "Delete this HTTP endpoint? Value sources that reference it will need to be repointed.",
|
||||
"http_endpoint.error.name_required": "Name is required",
|
||||
"http_endpoint.error.url_required": "URL is required",
|
||||
"http_endpoint.error.timeout_invalid": "Timeout must be a positive number",
|
||||
"http_endpoint.error.load": "Failed to load HTTP endpoint",
|
||||
"section.empty.http_endpoints": "No HTTP endpoints yet. Click + to add one.",
|
||||
"device.icon.entity.http_endpoint": "HTTP endpoint",
|
||||
"value_source.type.http": "HTTP Poll",
|
||||
"value_source.type.http.desc": "Polls an HTTP endpoint at a fixed cadence and maps the extracted value to 0-1.",
|
||||
"value_source.http.endpoint": "HTTP Endpoint:",
|
||||
"value_source.http.endpoint.hint": "Pick a saved endpoint from the HTTP integrations tab.",
|
||||
"value_source.http.json_path": "JSON Path:",
|
||||
"value_source.http.json_path.hint": "Empty = use raw response body. Use dotted/indexed path, e.g. MediaContainer.Metadata[0].title",
|
||||
"value_source.http.interval": "Interval (s):",
|
||||
"value_source.http.interval.hint": "Polling cadence in seconds. Multiple value sources can share one endpoint at different intervals.",
|
||||
"value_source.http.min_value": "Min Value:",
|
||||
"value_source.http.min_value.hint": "Raw extracted value that maps to output 0.0 (for normalisation).",
|
||||
"value_source.http.max_value": "Max Value:",
|
||||
"value_source.http.max_value.hint": "Raw extracted value that maps to output 1.0 (for normalisation).",
|
||||
"value_source.http.modulator.summary": "Modulator mapping (optional)",
|
||||
"value_source.http.modulator.hint": "Only used when this source drives brightness or color. Automation rules read the raw extracted value and ignore these settings.",
|
||||
"value_source.http.endpoint_required": "HTTP endpoint is required",
|
||||
"value_source.http.interval_invalid": "Interval must be at least 1 second",
|
||||
"automations.rule.http_poll": "HTTP Poll",
|
||||
"automations.rule.http_poll.desc": "Activate when the latest extracted value from an HTTP value source matches.",
|
||||
"automations.rule.http_poll.hint": "Compares the latest extracted value against your input. The value source decides what gets extracted (raw body or JSON path).",
|
||||
"automations.rule.http_poll.value_source": "HTTP Value Source",
|
||||
"automations.rule.http_poll.operator": "Operator",
|
||||
"automations.rule.http_poll.value": "Value",
|
||||
"automations.rule.http_poll.value.placeholder": "playing",
|
||||
"automations.rule.http_poll.no_source": "(no source)",
|
||||
"automations.rule.http_poll.raw_body": "Raw body",
|
||||
"automations.rule.http_poll.operator.equals": "Equals",
|
||||
"automations.rule.http_poll.operator.equals.desc": "Exact string match.",
|
||||
"automations.rule.http_poll.operator.not_equals": "Not equals",
|
||||
"automations.rule.http_poll.operator.not_equals.desc": "Activates when the value is anything other than this.",
|
||||
"automations.rule.http_poll.operator.contains": "Contains",
|
||||
"automations.rule.http_poll.operator.contains.desc": "Substring match.",
|
||||
"automations.rule.http_poll.operator.regex": "Regex",
|
||||
"automations.rule.http_poll.operator.regex.desc": "JavaScript-style regular expression.",
|
||||
"automations.rule.http_poll.operator.gt": "Greater than",
|
||||
"automations.rule.http_poll.operator.gt.desc": "Numeric comparison (>) — requires numeric output.",
|
||||
"automations.rule.http_poll.operator.lt": "Less than",
|
||||
"automations.rule.http_poll.operator.lt.desc": "Numeric comparison (<) — requires numeric output.",
|
||||
"automations.rule.http_poll.operator.exists": "Exists",
|
||||
"automations.rule.http_poll.operator.exists.desc": "Activates whenever a value is successfully extracted (ignores the value)."
|
||||
}
|
||||
|
||||
@@ -1393,17 +1393,17 @@
|
||||
"color_strip.delete.referenced": "Невозможно удалить: источник используется в цели",
|
||||
"color_strip.error.name_required": "Введите название",
|
||||
"color_strip.type": "Тип:",
|
||||
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами. Композит накладывает несколько источников как смешанные слои. Аудиореактив управляет LED от аудиосигнала в реальном времени. API-ввод принимает массивы цветов LED от внешних клиентов через REST или WebSocket.",
|
||||
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Один цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами. Композит накладывает несколько источников как смешанные слои. Аудиореактив управляет LED от аудиосигнала в реальном времени. API-ввод принимает массивы цветов LED от внешних клиентов через REST или WebSocket.",
|
||||
"color_strip.type.picture": "Источник изображения",
|
||||
"color_strip.type.picture.desc": "Цвета из захвата экрана",
|
||||
"color_strip.type.picture_advanced": "Мультимонитор",
|
||||
"color_strip.type.picture_advanced.desc": "Калибровка линиями по нескольким мониторам",
|
||||
"color_strip.type.static": "Статический цвет",
|
||||
"color_strip.type.static.desc": "Заливка одним цветом",
|
||||
"color_strip.type.single_color": "Один цвет",
|
||||
"color_strip.type.single_color.desc": "Заливка одним цветом",
|
||||
"color_strip.type.gradient": "Градиент",
|
||||
"color_strip.type.gradient.desc": "Плавный переход цветов по ленте",
|
||||
"color_strip.static_color": "Цвет:",
|
||||
"color_strip.static_color.hint": "Статический цвет, который будет отправлен на все светодиоды полосы.",
|
||||
"color_strip.single_color": "Цвет:",
|
||||
"color_strip.single_color.hint": "Один сплошной цвет, который будет отправлен на все светодиоды полосы.",
|
||||
"color_strip.gradient.preview": "Градиент:",
|
||||
"color_strip.gradient.preview.hint": "Предпросмотр градиента. Нажмите на дорожку маркеров чтобы добавить остановку. Перетащите маркеры для изменения позиции.",
|
||||
"color_strip.gradient.stops": "Цветовые остановки:",
|
||||
@@ -2641,5 +2641,89 @@
|
||||
"pairing.success": "Успешно сопряжено",
|
||||
"pairing.not_ready": "Устройство не ответило. Нажмите кнопку сопряжения на устройстве и попробуйте снова.",
|
||||
"pairing.failed": "Сопряжение не удалось: {detail}",
|
||||
"pairing.failed_prefix": "Сопряжение не удалось:"
|
||||
"pairing.failed_prefix": "Сопряжение не удалось:",
|
||||
"streams.group.http": "HTTP",
|
||||
"http_endpoint.group.title": "HTTP-эндпоинты",
|
||||
"http_endpoint.add": "Добавить HTTP-эндпоинт",
|
||||
"http_endpoint.edit": "Изменить HTTP-эндпоинт",
|
||||
"http_endpoint.section.request": "Запрос",
|
||||
"http_endpoint.section.headers": "Заголовки",
|
||||
"http_endpoint.name": "Имя:",
|
||||
"http_endpoint.name.placeholder": "Plex now-playing",
|
||||
"http_endpoint.name.hint": "Описательное имя для этого эндпоинта",
|
||||
"http_endpoint.url": "URL:",
|
||||
"http_endpoint.url.hint": "Полный http(s)-URL для опроса. Локальные адреса разрешены.",
|
||||
"http_endpoint.method": "Метод:",
|
||||
"http_endpoint.method.get.desc": "Получить тело ответа.",
|
||||
"http_endpoint.method.head.desc": "Только код статуса — без тела. Подходит для проверки доступности.",
|
||||
"http_endpoint.auth_token": "Токен авторизации (необязательно):",
|
||||
"http_endpoint.auth_token.hint": "Отправляется как 'Authorization: Bearer <token>'. Добавьте свой Authorization-заголовок, чтобы переопределить.",
|
||||
"http_endpoint.auth_token.edit_hint": "Оставьте пустым, чтобы сохранить текущий токен",
|
||||
"http_endpoint.auth_token.reveal": "Показать / скрыть токен",
|
||||
"http_endpoint.auth.set": "Авторизация",
|
||||
"http_endpoint.timeout": "Таймаут (с):",
|
||||
"http_endpoint.timeout.hint": "Максимальное время ожидания одного запроса (в секундах).",
|
||||
"http_endpoint.test": "Тестовый запрос",
|
||||
"http_endpoint.test.pending": "Проверка…",
|
||||
"http_endpoint.test.success": "Успех",
|
||||
"http_endpoint.test.failed": "Ошибка",
|
||||
"http_endpoint.test.body.json": "JSON-тело",
|
||||
"http_endpoint.test.body.text": "Тело ответа",
|
||||
"http_endpoint.headers": "Заголовки:",
|
||||
"http_endpoint.headers.hint": "Дополнительные заголовки запроса (например, X-API-Key, Accept).",
|
||||
"http_endpoint.headers.add": "Добавить заголовок",
|
||||
"http_endpoint.headers.empty": "Нет дополнительных заголовков — будут отправлены значения по умолчанию.",
|
||||
"http_endpoint.headers.name_placeholder": "Имя заголовка",
|
||||
"http_endpoint.headers.value_placeholder": "Значение",
|
||||
"http_endpoint.headers.count": "{n} заголовков",
|
||||
"http_endpoint.description": "Описание (необязательно):",
|
||||
"http_endpoint.created": "HTTP-эндпоинт создан",
|
||||
"http_endpoint.updated": "HTTP-эндпоинт обновлён",
|
||||
"http_endpoint.deleted": "HTTP-эндпоинт удалён",
|
||||
"http_endpoint.delete.confirm": "Удалить этот HTTP-эндпоинт? Источники-значений, ссылающиеся на него, потребуется перенастроить.",
|
||||
"http_endpoint.error.name_required": "Имя обязательно",
|
||||
"http_endpoint.error.url_required": "URL обязателен",
|
||||
"http_endpoint.error.timeout_invalid": "Таймаут должен быть положительным числом",
|
||||
"http_endpoint.error.load": "Не удалось загрузить HTTP-эндпоинт",
|
||||
"section.empty.http_endpoints": "Пока нет HTTP-эндпоинтов. Нажмите +, чтобы добавить.",
|
||||
"device.icon.entity.http_endpoint": "HTTP-эндпоинт",
|
||||
"value_source.type.http": "HTTP-опрос",
|
||||
"value_source.type.http.desc": "Периодически опрашивает HTTP-эндпоинт и сопоставляет извлечённое значение с диапазоном 0–1.",
|
||||
"value_source.http.endpoint": "HTTP-эндпоинт:",
|
||||
"value_source.http.endpoint.hint": "Выберите сохранённый эндпоинт во вкладке HTTP-интеграций.",
|
||||
"value_source.http.json_path": "JSON-путь:",
|
||||
"value_source.http.json_path.hint": "Пусто = использовать необработанное тело ответа. Используйте путь с точками и индексами, например MediaContainer.Metadata[0].title",
|
||||
"value_source.http.interval": "Интервал (с):",
|
||||
"value_source.http.interval.hint": "Период опроса в секундах. Несколько источников могут использовать один эндпоинт с разными интервалами.",
|
||||
"value_source.http.min_value": "Мин. значение:",
|
||||
"value_source.http.min_value.hint": "Извлечённое значение, которое отображается в выход 0.0 (для нормализации).",
|
||||
"value_source.http.max_value": "Макс. значение:",
|
||||
"value_source.http.max_value.hint": "Извлечённое значение, которое отображается в выход 1.0 (для нормализации).",
|
||||
"value_source.http.modulator.summary": "Сопоставление для модулятора (необязательно)",
|
||||
"value_source.http.modulator.hint": "Используется только когда этот источник управляет яркостью или цветом. Правила автоматизации читают извлечённое значение в исходном виде и игнорируют эти настройки.",
|
||||
"value_source.http.endpoint_required": "Требуется HTTP-эндпоинт",
|
||||
"value_source.http.interval_invalid": "Интервал должен быть не меньше 1 секунды",
|
||||
"automations.rule.http_poll": "HTTP-опрос",
|
||||
"automations.rule.http_poll.desc": "Срабатывает, когда последнее значение HTTP-источника соответствует условию.",
|
||||
"automations.rule.http_poll.hint": "Сравнивает последнее извлечённое значение с вашим вводом. Что именно извлекается (тело или JSON-путь), задаётся в источнике-значении.",
|
||||
"automations.rule.http_poll.value_source": "HTTP источник-значения",
|
||||
"automations.rule.http_poll.operator": "Оператор",
|
||||
"automations.rule.http_poll.value": "Значение",
|
||||
"automations.rule.http_poll.value.placeholder": "playing",
|
||||
"automations.rule.http_poll.no_source": "(нет источника)",
|
||||
"automations.rule.http_poll.raw_body": "Тело ответа",
|
||||
"automations.rule.http_poll.operator.equals": "Равно",
|
||||
"automations.rule.http_poll.operator.equals.desc": "Точное совпадение строки.",
|
||||
"automations.rule.http_poll.operator.not_equals": "Не равно",
|
||||
"automations.rule.http_poll.operator.not_equals.desc": "Срабатывает, когда значение отличается.",
|
||||
"automations.rule.http_poll.operator.contains": "Содержит",
|
||||
"automations.rule.http_poll.operator.contains.desc": "Поиск подстроки.",
|
||||
"automations.rule.http_poll.operator.regex": "Regex",
|
||||
"automations.rule.http_poll.operator.regex.desc": "Регулярное выражение в стиле JavaScript.",
|
||||
"automations.rule.http_poll.operator.gt": "Больше",
|
||||
"automations.rule.http_poll.operator.gt.desc": "Числовое сравнение (>) — нужно числовое значение.",
|
||||
"automations.rule.http_poll.operator.lt": "Меньше",
|
||||
"automations.rule.http_poll.operator.lt.desc": "Числовое сравнение (<) — нужно числовое значение.",
|
||||
"automations.rule.http_poll.operator.exists": "Существует",
|
||||
"automations.rule.http_poll.operator.exists.desc": "Срабатывает, когда значение успешно извлечено (само значение игнорируется)."
|
||||
}
|
||||
|
||||
@@ -1390,17 +1390,17 @@
|
||||
"color_strip.delete.referenced": "无法删除:此源正在被目标使用",
|
||||
"color_strip.error.name_required": "请输入名称",
|
||||
"color_strip.type": "类型:",
|
||||
"color_strip.type.hint": "图片源从屏幕采集推导 LED 颜色。静态颜色用单一颜色填充所有 LED。渐变在所有 LED 上分布颜色渐变。颜色循环平滑循环用户定义的颜色列表。组合将多个源作为混合图层叠加。音频响应从实时音频输入驱动 LED。API 输入通过 REST 或 WebSocket 从外部客户端接收原始 LED 颜色。",
|
||||
"color_strip.type.hint": "图片源从屏幕采集推导 LED 颜色。单色用单一颜色填充所有 LED。渐变在所有 LED 上分布颜色渐变。颜色循环平滑循环用户定义的颜色列表。组合将多个源作为混合图层叠加。音频响应从实时音频输入驱动 LED。API 输入通过 REST 或 WebSocket 从外部客户端接收原始 LED 颜色。",
|
||||
"color_strip.type.picture": "图片源",
|
||||
"color_strip.type.picture.desc": "从屏幕捕获获取颜色",
|
||||
"color_strip.type.picture_advanced": "多显示器",
|
||||
"color_strip.type.picture_advanced.desc": "跨显示器的线条校准",
|
||||
"color_strip.type.static": "静态颜色",
|
||||
"color_strip.type.static.desc": "单色填充",
|
||||
"color_strip.type.single_color": "单色",
|
||||
"color_strip.type.single_color.desc": "单色填充",
|
||||
"color_strip.type.gradient": "渐变",
|
||||
"color_strip.type.gradient.desc": "LED上的平滑颜色过渡",
|
||||
"color_strip.static_color": "颜色:",
|
||||
"color_strip.static_color.hint": "将发送到灯带上所有 LED 的纯色。",
|
||||
"color_strip.single_color": "颜色:",
|
||||
"color_strip.single_color.hint": "将发送到灯带上所有 LED 的纯色。",
|
||||
"color_strip.gradient.preview": "渐变:",
|
||||
"color_strip.gradient.preview.hint": "可视预览。点击下方标记轨道添加色标。拖动标记重新定位。",
|
||||
"color_strip.gradient.stops": "色标:",
|
||||
@@ -2636,5 +2636,89 @@
|
||||
"pairing.success": "配对成功",
|
||||
"pairing.not_ready": "设备未响应。请按下设备上的配对按钮后重试。",
|
||||
"pairing.failed": "配对失败:{detail}",
|
||||
"pairing.failed_prefix": "配对失败:"
|
||||
"pairing.failed_prefix": "配对失败:",
|
||||
"streams.group.http": "HTTP",
|
||||
"http_endpoint.group.title": "HTTP 端点",
|
||||
"http_endpoint.add": "添加 HTTP 端点",
|
||||
"http_endpoint.edit": "编辑 HTTP 端点",
|
||||
"http_endpoint.section.request": "请求",
|
||||
"http_endpoint.section.headers": "请求头",
|
||||
"http_endpoint.name": "名称:",
|
||||
"http_endpoint.name.placeholder": "Plex 正在播放",
|
||||
"http_endpoint.name.hint": "此端点的描述性名称",
|
||||
"http_endpoint.url": "URL:",
|
||||
"http_endpoint.url.hint": "要轮询的完整 http(s) URL,允许使用本地地址。",
|
||||
"http_endpoint.method": "方法:",
|
||||
"http_endpoint.method.get.desc": "获取响应体。",
|
||||
"http_endpoint.method.head.desc": "仅返回状态码,不返回响应体。适合健康检查。",
|
||||
"http_endpoint.auth_token": "认证令牌(可选):",
|
||||
"http_endpoint.auth_token.hint": "作为 'Authorization: Bearer <token>' 发送。在请求头中添加自定义 Authorization 可覆盖。",
|
||||
"http_endpoint.auth_token.edit_hint": "留空以保留当前令牌",
|
||||
"http_endpoint.auth_token.reveal": "显示 / 隐藏令牌",
|
||||
"http_endpoint.auth.set": "认证",
|
||||
"http_endpoint.timeout": "超时(秒):",
|
||||
"http_endpoint.timeout.hint": "单次请求的最长等待秒数。",
|
||||
"http_endpoint.test": "测试请求",
|
||||
"http_endpoint.test.pending": "测试中…",
|
||||
"http_endpoint.test.success": "成功",
|
||||
"http_endpoint.test.failed": "失败",
|
||||
"http_endpoint.test.body.json": "JSON 主体",
|
||||
"http_endpoint.test.body.text": "响应主体",
|
||||
"http_endpoint.headers": "自定义请求头:",
|
||||
"http_endpoint.headers.hint": "可选的请求头(如 X-API-Key、Accept)。",
|
||||
"http_endpoint.headers.add": "添加请求头",
|
||||
"http_endpoint.headers.empty": "没有自定义请求头 — 将发送默认值。",
|
||||
"http_endpoint.headers.name_placeholder": "请求头名称",
|
||||
"http_endpoint.headers.value_placeholder": "值",
|
||||
"http_endpoint.headers.count": "{n} 个请求头",
|
||||
"http_endpoint.description": "描述(可选):",
|
||||
"http_endpoint.created": "已创建 HTTP 端点",
|
||||
"http_endpoint.updated": "已更新 HTTP 端点",
|
||||
"http_endpoint.deleted": "已删除 HTTP 端点",
|
||||
"http_endpoint.delete.confirm": "删除此 HTTP 端点?引用它的值源需要重新指向其他端点。",
|
||||
"http_endpoint.error.name_required": "需要名称",
|
||||
"http_endpoint.error.url_required": "需要 URL",
|
||||
"http_endpoint.error.timeout_invalid": "超时必须为正数",
|
||||
"http_endpoint.error.load": "加载 HTTP 端点失败",
|
||||
"section.empty.http_endpoints": "暂无 HTTP 端点。点击 + 添加一个。",
|
||||
"device.icon.entity.http_endpoint": "HTTP 端点",
|
||||
"value_source.type.http": "HTTP 轮询",
|
||||
"value_source.type.http.desc": "按固定间隔轮询 HTTP 端点,并将提取的值映射到 0–1。",
|
||||
"value_source.http.endpoint": "HTTP 端点:",
|
||||
"value_source.http.endpoint.hint": "从 HTTP 集成选项卡中选择已保存的端点。",
|
||||
"value_source.http.json_path": "JSON 路径:",
|
||||
"value_source.http.json_path.hint": "为空则使用原始响应体。使用点号/索引路径,例如 MediaContainer.Metadata[0].title",
|
||||
"value_source.http.interval": "间隔(秒):",
|
||||
"value_source.http.interval.hint": "轮询间隔(秒)。多个值源可以以不同间隔共享同一端点。",
|
||||
"value_source.http.min_value": "最小值:",
|
||||
"value_source.http.min_value.hint": "映射到输出 0.0 的原始值(用于归一化)。",
|
||||
"value_source.http.max_value": "最大值:",
|
||||
"value_source.http.max_value.hint": "映射到输出 1.0 的原始值(用于归一化)。",
|
||||
"value_source.http.modulator.summary": "调制映射(可选)",
|
||||
"value_source.http.modulator.hint": "仅当此源用于驱动亮度或颜色时使用。自动化规则会直接读取提取的原始值,并忽略这些设置。",
|
||||
"value_source.http.endpoint_required": "需要 HTTP 端点",
|
||||
"value_source.http.interval_invalid": "间隔至少为 1 秒",
|
||||
"automations.rule.http_poll": "HTTP 轮询",
|
||||
"automations.rule.http_poll.desc": "当 HTTP 值源的最新提取值匹配时激活。",
|
||||
"automations.rule.http_poll.hint": "将最新的提取值与您的输入进行比较。提取的内容(原始响应体或 JSON 路径)由值源决定。",
|
||||
"automations.rule.http_poll.value_source": "HTTP 值源",
|
||||
"automations.rule.http_poll.operator": "运算符",
|
||||
"automations.rule.http_poll.value": "值",
|
||||
"automations.rule.http_poll.value.placeholder": "playing",
|
||||
"automations.rule.http_poll.no_source": "(无来源)",
|
||||
"automations.rule.http_poll.raw_body": "原始响应体",
|
||||
"automations.rule.http_poll.operator.equals": "等于",
|
||||
"automations.rule.http_poll.operator.equals.desc": "精确字符串匹配。",
|
||||
"automations.rule.http_poll.operator.not_equals": "不等于",
|
||||
"automations.rule.http_poll.operator.not_equals.desc": "当值不同时激活。",
|
||||
"automations.rule.http_poll.operator.contains": "包含",
|
||||
"automations.rule.http_poll.operator.contains.desc": "子字符串匹配。",
|
||||
"automations.rule.http_poll.operator.regex": "正则",
|
||||
"automations.rule.http_poll.operator.regex.desc": "JavaScript 风格的正则表达式。",
|
||||
"automations.rule.http_poll.operator.gt": "大于",
|
||||
"automations.rule.http_poll.operator.gt.desc": "数值比较 (>) — 需要数值输出。",
|
||||
"automations.rule.http_poll.operator.lt": "小于",
|
||||
"automations.rule.http_poll.operator.lt.desc": "数值比较 (<) — 需要数值输出。",
|
||||
"automations.rule.http_poll.operator.exists": "存在",
|
||||
"automations.rule.http_poll.operator.exists.desc": "只要成功提取出值就激活(忽略值本身)。"
|
||||
}
|
||||
|
||||
@@ -201,6 +201,49 @@ class HomeAssistantRule(Rule):
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HTTPPollRule(Rule):
|
||||
"""Activate based on a value extracted by an HTTP value source.
|
||||
|
||||
The extraction (URL, auth, json_path, cadence) lives on an
|
||||
``HTTPValueSource``; this rule just references the value source and
|
||||
compares its current raw value to ``value`` using ``operator``.
|
||||
"""
|
||||
|
||||
rule_type: str = "http_poll"
|
||||
value_source_id: str = "" # references an HTTPValueSource
|
||||
operator: str = "equals" # equals | not_equals | contains | regex | gt | lt | exists
|
||||
value: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["value_source_id"] = self.value_source_id
|
||||
d["operator"] = self.operator
|
||||
d["value"] = self.value
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "HTTPPollRule":
|
||||
# Accept legacy ``http_source_id`` + ``json_path`` payloads from any
|
||||
# in-flight v1 data and ignore them — the new shape only needs the
|
||||
# value_source_id. Log a warning so a dev DB that still has the old
|
||||
# form on disk is visible: such a row will load with
|
||||
# ``value_source_id=""`` and the rule will silently evaluate to
|
||||
# False forever otherwise.
|
||||
if "http_source_id" in data or "json_path" in data:
|
||||
logger.warning(
|
||||
"Migrating legacy http_poll rule (had keys: %s). "
|
||||
"value_source_id is empty; re-point the rule at an HTTPValueSource "
|
||||
"and re-save to clear this warning.",
|
||||
sorted(k for k in ("http_source_id", "json_path") if k in data),
|
||||
)
|
||||
return cls(
|
||||
value_source_id=data.get("value_source_id", ""),
|
||||
operator=data.get("operator", "equals"),
|
||||
value=data.get("value", ""),
|
||||
)
|
||||
|
||||
|
||||
_RULE_MAP: Dict[str, Type[Rule]] = {
|
||||
"application": ApplicationRule,
|
||||
"time_of_day": TimeOfDayRule,
|
||||
@@ -210,6 +253,7 @@ _RULE_MAP: Dict[str, Type[Rule]] = {
|
||||
"webhook": WebhookRule,
|
||||
"startup": StartupRule,
|
||||
"home_assistant": HomeAssistantRule,
|
||||
"http_poll": HTTPPollRule,
|
||||
# Legacy: "always" maps to StartupRule for migration
|
||||
"always": StartupRule,
|
||||
}
|
||||
|
||||
@@ -32,6 +32,15 @@ class BaseSqliteStore(Generic[T]):
|
||||
self._items: Dict[str, T] = {}
|
||||
self._deserializer = deserializer
|
||||
self._lock = threading.RLock()
|
||||
# Apply pending JSON-blob data migrations before loading rows so the
|
||||
# in-memory cache reflects the canonical, post-migration shape. The
|
||||
# runner is idempotent across stores — it consults a dedicated
|
||||
# ``data_migrations`` audit table — so each store construction may
|
||||
# invoke it without re-running already-applied migrations.
|
||||
# Imported lazily to avoid a circular dependency at module load.
|
||||
from ledgrab.storage.data_migrations import ALL_MIGRATIONS, MigrationRunner
|
||||
|
||||
MigrationRunner(db).run(ALL_MIGRATIONS)
|
||||
self._load()
|
||||
|
||||
# -- I/O -----------------------------------------------------------------
|
||||
|
||||
@@ -7,7 +7,7 @@ calibration, color correction, smoothing, and FPS.
|
||||
Current types:
|
||||
PictureColorStripSource — derives LED colors from a single PictureSource (simple 4-edge calibration)
|
||||
AdvancedPictureColorStripSource — line-based calibration across multiple PictureSources
|
||||
StaticColorStripSource — constant solid color fills all LEDs
|
||||
SingleColorStripSource — constant solid color fills all LEDs
|
||||
GradientColorStripSource — linear gradient across all LEDs from user-defined color stops
|
||||
AudioColorStripSource — audio-reactive visualization (spectrum, beat pulse, VU meter)
|
||||
ApiInputColorStripSource — receives raw LED colors from external clients via REST/WebSocket
|
||||
@@ -366,8 +366,8 @@ class AdvancedPictureColorStripSource(ColorStripSource):
|
||||
|
||||
|
||||
@dataclass
|
||||
class StaticColorStripSource(ColorStripSource):
|
||||
"""Color strip source that fills all LEDs with a single static color.
|
||||
class SingleColorStripSource(ColorStripSource):
|
||||
"""Color strip source that fills all LEDs with a single solid color.
|
||||
|
||||
No capture or processing -- the entire LED strip is set to one constant
|
||||
RGB color. Useful for solid-color accents or as a placeholder while
|
||||
@@ -384,11 +384,11 @@ class StaticColorStripSource(ColorStripSource):
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "StaticColorStripSource":
|
||||
def from_dict(cls, data: dict) -> "SingleColorStripSource":
|
||||
common = _parse_css_common(data)
|
||||
return cls(
|
||||
**common,
|
||||
source_type="static",
|
||||
source_type="single_color",
|
||||
color=BindableColor.from_raw(data.get("color"), default=[255, 255, 255]),
|
||||
animation=data.get("animation"),
|
||||
)
|
||||
@@ -412,7 +412,7 @@ class StaticColorStripSource(ColorStripSource):
|
||||
return cls(
|
||||
id=id,
|
||||
name=name,
|
||||
source_type="static",
|
||||
source_type="single_color",
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
description=description,
|
||||
@@ -1823,7 +1823,10 @@ class MathWaveColorStripSource(ColorStripSource):
|
||||
_SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = {
|
||||
"picture": PictureColorStripSource,
|
||||
"picture_advanced": AdvancedPictureColorStripSource,
|
||||
"static": StaticColorStripSource,
|
||||
"single_color": SingleColorStripSource,
|
||||
# Legacy alias: pre-rename rows used "static". Kept so old DBs deserialize;
|
||||
# ColorStripStore migrates the on-disk source_type to "single_color" on startup.
|
||||
"static": SingleColorStripSource,
|
||||
"gradient": GradientColorStripSource,
|
||||
"effect": EffectColorStripSource,
|
||||
"audio": AudioColorStripSource,
|
||||
|
||||
@@ -23,7 +23,13 @@ MAX_COMPOSITE_DEPTH = 4
|
||||
|
||||
|
||||
class ColorStripStore(BaseSqliteStore[ColorStripSource]):
|
||||
"""Persistent storage for color strip sources."""
|
||||
"""Persistent storage for color strip sources.
|
||||
|
||||
JSON-blob field renames (e.g. legacy ``source_type='static'`` →
|
||||
``'single_color'``) are handled by the central
|
||||
:mod:`ledgrab.storage.data_migrations` runner, which executes once per
|
||||
database from :meth:`Database._ensure_schema`.
|
||||
"""
|
||||
|
||||
_table_name = "color_strip_sources"
|
||||
_entity_name = "Color strip source"
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Versioned data migrations for stored JSON entities.
|
||||
|
||||
Each migration is a small class with a stable ``name`` (used as the idempotency
|
||||
key) and an ``apply(db)`` method that performs the change inside a transaction.
|
||||
The ``MigrationRunner`` keeps a ``data_migrations`` table that records which
|
||||
migrations have already executed; subsequent runs are a no-op.
|
||||
|
||||
This replaces ad-hoc per-store migrations that were rewriting raw JSON via
|
||||
``str.replace`` — that approach corrupted nested fields whose values happened
|
||||
to share a substring with the renamed key, and had no transaction or audit.
|
||||
|
||||
Adding a new migration
|
||||
----------------------
|
||||
|
||||
1. Subclass :class:`DataMigration`, give it a unique ``name`` (prefix with the
|
||||
next sequence number, e.g. ``"002_..."``) and implement ``apply``.
|
||||
2. Append it to :data:`ALL_MIGRATIONS` in commit order. Never reorder existing
|
||||
entries — the runner records each by name.
|
||||
3. The first ``ColorStripStore`` (or any other store) construction triggers the
|
||||
runner; nothing else has to change.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ledgrab.storage.database import Database, is_writes_frozen
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DataMigration(ABC):
|
||||
"""One JSON-blob migration step.
|
||||
|
||||
Subclasses must declare a class-level ``name`` and implement ``apply``.
|
||||
Implementations must operate on the supplied ``conn`` (an already-open
|
||||
transaction). They MUST NOT commit or open nested transactions — the
|
||||
:class:`MigrationRunner` owns the transaction so the data UPDATEs and the
|
||||
audit-table INSERT are atomic.
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
|
||||
@abstractmethod
|
||||
def apply(self, conn: sqlite3.Connection) -> int:
|
||||
"""Perform the migration inside the supplied transaction.
|
||||
|
||||
Return the number of rows changed.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MigrationRecord:
|
||||
"""A row in the ``data_migrations`` audit table."""
|
||||
|
||||
name: str
|
||||
applied_at: str
|
||||
rows_changed: int
|
||||
|
||||
|
||||
class MigrationRunner:
|
||||
"""Apply pending data migrations exactly once per database.
|
||||
|
||||
Each call to :meth:`run` opens a single transaction that covers (a) the
|
||||
"is this migration already applied?" check, (b) the migration body, and
|
||||
(c) the audit INSERT. That guarantees:
|
||||
|
||||
* a partial-failure cannot leave data rewritten but unrecorded;
|
||||
* concurrent stores constructing on multiple threads cannot race each
|
||||
other into a UNIQUE-constraint crash on the audit table.
|
||||
|
||||
The runner skips silently when writes are frozen so a post-restore boot
|
||||
does not mutate the freshly-restored database before the imminent restart.
|
||||
"""
|
||||
|
||||
_TABLE = "data_migrations"
|
||||
|
||||
def __init__(self, db: Database):
|
||||
self._db = db
|
||||
self._ensure_table()
|
||||
|
||||
def _ensure_table(self) -> None:
|
||||
# CREATE TABLE is idempotent thanks to IF NOT EXISTS; running on every
|
||||
# startup is cheap. ``Database.execute`` does NOT honour the
|
||||
# ``is_writes_frozen`` guard (DDL is always permitted), so the audit
|
||||
# table reliably exists even on a frozen boot.
|
||||
self._db.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {self._TABLE} (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL,
|
||||
rows_changed INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def applied_names(self) -> set[str]:
|
||||
"""Return the set of migration names already recorded as applied."""
|
||||
cursor = self._db.execute(f"SELECT name FROM {self._TABLE}")
|
||||
return {row["name"] for row in cursor.fetchall()}
|
||||
|
||||
def run(self, migrations: list[DataMigration]) -> list[MigrationRecord]:
|
||||
"""Apply each migration in *migrations* that hasn't been recorded yet.
|
||||
|
||||
Returns the records for migrations that actually executed this call.
|
||||
Returns an empty list when writes are frozen.
|
||||
"""
|
||||
if is_writes_frozen():
|
||||
# Frozen-write databases are read-only between a restore and the
|
||||
# imminent restart. Surface this clearly because the in-memory
|
||||
# state may briefly reflect pre-migration data.
|
||||
logger.warning("Data migrations skipped: writes are frozen (restart expected).")
|
||||
return []
|
||||
|
||||
# Validate names up-front so an unnamed migration aborts before any
|
||||
# transaction work.
|
||||
for migration in migrations:
|
||||
if not migration.name:
|
||||
raise ValueError(
|
||||
f"DataMigration {type(migration).__name__} must declare a non-empty name"
|
||||
)
|
||||
|
||||
executed: list[MigrationRecord] = []
|
||||
for migration in migrations:
|
||||
# Use a separate transaction per migration so a failure rolls back
|
||||
# only the failing one and any earlier (already-recorded)
|
||||
# migrations keep their audit rows.
|
||||
with self._db.transaction() as conn:
|
||||
already_applied = conn.execute(
|
||||
f"SELECT 1 FROM {self._TABLE} WHERE name = ?",
|
||||
(migration.name,),
|
||||
).fetchone()
|
||||
if already_applied:
|
||||
continue
|
||||
logger.info("Applying data migration: %s", migration.name)
|
||||
rows_changed = int(migration.apply(conn))
|
||||
applied_at = datetime.now(timezone.utc).isoformat()
|
||||
# INSERT OR IGNORE defends against a hypothetical concurrent
|
||||
# writer that recorded the same name between the SELECT above
|
||||
# and this INSERT (the RLock serialises this in practice, but
|
||||
# the constraint is the durable contract).
|
||||
conn.execute(
|
||||
f"INSERT OR IGNORE INTO {self._TABLE} "
|
||||
f"(name, applied_at, rows_changed) VALUES (?, ?, ?)",
|
||||
(migration.name, applied_at, rows_changed),
|
||||
)
|
||||
executed.append(
|
||||
MigrationRecord(
|
||||
name=migration.name,
|
||||
applied_at=applied_at,
|
||||
rows_changed=rows_changed,
|
||||
)
|
||||
)
|
||||
if rows_changed:
|
||||
logger.warning("Migration %s rewrote %d row(s)", migration.name, rows_changed)
|
||||
|
||||
if executed:
|
||||
logger.info(
|
||||
"Applied %d data migration(s): %s",
|
||||
len(executed),
|
||||
", ".join(r.name for r in executed),
|
||||
)
|
||||
return executed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Concrete migrations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StaticToSingleColorMigration(DataMigration):
|
||||
"""Rename the legacy ``source_type='static'`` color-strip kind to ``single_color``.
|
||||
|
||||
The pre-rename rows in ``color_strip_sources`` used ``source_type='static'``;
|
||||
the new canonical value is ``single_color``. The previous in-line string
|
||||
replace risked rewriting unrelated substrings (e.g. an animation type named
|
||||
``static_wave``). This migration parses the JSON, mutates the single field
|
||||
we care about, and writes the canonical serialization back. Runs inside
|
||||
the runner's transaction.
|
||||
"""
|
||||
|
||||
name = "001_color_strip_static_to_single_color"
|
||||
|
||||
def apply(self, conn: sqlite3.Connection) -> int:
|
||||
rows_changed = 0
|
||||
rows = conn.execute("SELECT id, data FROM [color_strip_sources]").fetchall()
|
||||
for row in rows:
|
||||
blob = row["data"]
|
||||
try:
|
||||
parsed = json.loads(blob)
|
||||
except json.JSONDecodeError as e:
|
||||
# A corrupt blob is a pre-existing problem; do not crash the
|
||||
# migration. The store's own load path will surface it.
|
||||
logger.warning(
|
||||
"Skipping corrupt color_strip_sources row %s during migration: %s",
|
||||
row["id"],
|
||||
e,
|
||||
)
|
||||
continue
|
||||
if parsed.get("source_type") != "static":
|
||||
continue
|
||||
parsed["source_type"] = "single_color"
|
||||
conn.execute(
|
||||
"UPDATE [color_strip_sources] SET data = ? WHERE id = ?",
|
||||
(json.dumps(parsed, ensure_ascii=False), row["id"]),
|
||||
)
|
||||
rows_changed += 1
|
||||
return rows_changed
|
||||
|
||||
|
||||
# Master list — ORDER MATTERS. Append new migrations; never reorder.
|
||||
ALL_MIGRATIONS: list[DataMigration] = [
|
||||
StaticToSingleColorMigration(),
|
||||
]
|
||||
@@ -0,0 +1,126 @@
|
||||
"""HTTP endpoint data model.
|
||||
|
||||
An ``HTTPEndpoint`` is a *connection definition*: where to fetch (URL),
|
||||
how to authenticate (bearer token), what headers to send, and how long
|
||||
to wait. It owns nothing about *what* to extract or *how often* to poll
|
||||
— those concerns live on consumers (HTTPValueSource for periodic polling
|
||||
+ extraction; potentially other consumers in future).
|
||||
|
||||
Mirrors the MQTT/HA source pattern (storage/mqtt_source.py,
|
||||
storage/home_assistant_source.py).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from ledgrab.utils import secret_box
|
||||
|
||||
|
||||
def _parse_common(data: dict) -> dict:
|
||||
"""Extract common fields from a dict, parsing timestamps."""
|
||||
created = data.get("created_at", "")
|
||||
updated = data.get("updated_at", "")
|
||||
return {
|
||||
"id": data["id"],
|
||||
"name": data["name"],
|
||||
"created_at": (
|
||||
datetime.fromisoformat(created)
|
||||
if isinstance(created, str) and created
|
||||
else datetime.now(timezone.utc)
|
||||
),
|
||||
"updated_at": (
|
||||
datetime.fromisoformat(updated)
|
||||
if isinstance(updated, str) and updated
|
||||
else datetime.now(timezone.utc)
|
||||
),
|
||||
"description": data.get("description"),
|
||||
"tags": data.get("tags") or [],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class HTTPEndpoint:
|
||||
"""HTTP endpoint connection configuration."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
url: str = ""
|
||||
method: str = "GET" # "GET" | "HEAD"
|
||||
auth_token: str = "" # convenience: becomes Authorization: Bearer <token>
|
||||
headers: Dict[str, str] = field(default_factory=dict)
|
||||
timeout_s: float = 10.0
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
icon: str = ""
|
||||
icon_color: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Invariant: ``self.auth_token`` is always plaintext at runtime.
|
||||
# If a caller constructed this from a raw dict that still holds the
|
||||
# encrypted envelope, decrypt now so ``build_request_headers``
|
||||
# doesn't accidentally send ``Authorization: Bearer <envelope>``.
|
||||
if self.auth_token and secret_box.is_encrypted(self.auth_token):
|
||||
try:
|
||||
self.auth_token = secret_box.decrypt(self.auth_token)
|
||||
except Exception:
|
||||
self.auth_token = ""
|
||||
|
||||
@property
|
||||
def plaintext_token(self) -> str:
|
||||
return secret_box.decrypt(self.auth_token) if self.auth_token else ""
|
||||
|
||||
def build_request_headers(self) -> Dict[str, str]:
|
||||
"""Compose the headers actually sent on a fetch.
|
||||
|
||||
Order: explicit ``headers`` first, ``Authorization`` derived from
|
||||
``auth_token`` only if the caller did not already supply one.
|
||||
The check is case-insensitive so a user-supplied ``authorization``
|
||||
(lower) wins over the bearer-token shortcut.
|
||||
"""
|
||||
result: Dict[str, str] = dict(self.headers)
|
||||
already_has_auth = any(k.lower() == "authorization" for k in result)
|
||||
if self.auth_token and not already_has_auth:
|
||||
result["Authorization"] = f"Bearer {self.auth_token}"
|
||||
return result
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
# Always persist auth_token in encrypted envelope form. If the field
|
||||
# already contains an envelope, encrypt() is a no-op.
|
||||
stored_token = secret_box.encrypt(self.auth_token) if self.auth_token else ""
|
||||
d = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"url": self.url,
|
||||
"method": self.method,
|
||||
"auth_token": stored_token,
|
||||
"headers": dict(self.headers),
|
||||
"timeout_s": self.timeout_s,
|
||||
"description": self.description,
|
||||
"tags": self.tags,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
if self.icon:
|
||||
d["icon"] = self.icon
|
||||
if self.icon_color:
|
||||
d["icon_color"] = self.icon_color
|
||||
return d
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict) -> "HTTPEndpoint":
|
||||
common = _parse_common(data)
|
||||
raw_token = data.get("auth_token", "")
|
||||
token = secret_box.decrypt(raw_token) if raw_token else ""
|
||||
return HTTPEndpoint(
|
||||
**common,
|
||||
url=data.get("url", ""),
|
||||
method=data.get("method", "GET"),
|
||||
auth_token=token,
|
||||
headers=dict(data.get("headers") or {}),
|
||||
timeout_s=float(data.get("timeout_s", 10.0)),
|
||||
icon=data.get("icon", ""),
|
||||
icon_color=data.get("icon_color", ""),
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""HTTP endpoint storage using SQLite. Mirrors MQTTSourceStore."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from ledgrab.storage.base_sqlite_store import BaseSqliteStore
|
||||
from ledgrab.storage.database import Database
|
||||
from ledgrab.storage.http_endpoint import HTTPEndpoint
|
||||
from ledgrab.utils import get_logger, secret_box
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class HTTPEndpointStore(BaseSqliteStore[HTTPEndpoint]):
|
||||
"""Persistent storage for HTTP endpoint connection definitions."""
|
||||
|
||||
_table_name = "http_endpoints"
|
||||
_entity_name = "HTTP endpoint"
|
||||
|
||||
def __init__(self, db: Database):
|
||||
super().__init__(db, HTTPEndpoint.from_dict)
|
||||
self._migrate_plaintext_tokens()
|
||||
|
||||
def _migrate_plaintext_tokens(self) -> None:
|
||||
"""Encrypt any stored auth tokens still in plaintext at rest."""
|
||||
migrated = 0
|
||||
try:
|
||||
rows = self._db.load_all(self._table_name)
|
||||
except Exception as exc:
|
||||
logger.error("Could not inspect rows for HTTP token migration: %s", exc)
|
||||
return
|
||||
for row in rows:
|
||||
sid = row.get("id")
|
||||
raw = row.get("auth_token", "") or ""
|
||||
if not sid or not raw:
|
||||
continue
|
||||
if secret_box.is_encrypted(raw):
|
||||
continue
|
||||
endpoint = self._items.get(sid)
|
||||
if endpoint is None:
|
||||
continue
|
||||
try:
|
||||
self._save_item(sid, endpoint)
|
||||
migrated += 1
|
||||
except Exception as exc:
|
||||
logger.error("Failed to migrate HTTP token for %s: %s", sid, exc)
|
||||
if migrated:
|
||||
logger.warning(
|
||||
"MIGRATION: encrypted %d plaintext HTTP auth token(s) at rest.",
|
||||
migrated,
|
||||
)
|
||||
|
||||
# Backward-compatible aliases (mirror MQTT store)
|
||||
get_all_endpoints = BaseSqliteStore.get_all
|
||||
get_endpoint = BaseSqliteStore.get
|
||||
delete_endpoint = BaseSqliteStore.delete
|
||||
|
||||
def create_endpoint(
|
||||
self,
|
||||
name: str,
|
||||
url: str,
|
||||
method: str = "GET",
|
||||
auth_token: str = "",
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout_s: float = 10.0,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
icon: Optional[str] = None,
|
||||
icon_color: Optional[str] = None,
|
||||
) -> HTTPEndpoint:
|
||||
if not url:
|
||||
raise ValueError("url is required")
|
||||
if method not in ("GET", "HEAD"):
|
||||
raise ValueError(f"Unsupported method: {method!r}. Use GET or HEAD.")
|
||||
if timeout_s <= 0:
|
||||
raise ValueError("timeout_s must be > 0")
|
||||
|
||||
self._check_name_unique(name)
|
||||
|
||||
eid = f"htep_{uuid.uuid4().hex[:8]}"
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
endpoint = HTTPEndpoint(
|
||||
id=eid,
|
||||
name=name,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
url=url,
|
||||
method=method,
|
||||
auth_token=auth_token,
|
||||
headers=dict(headers or {}),
|
||||
timeout_s=timeout_s,
|
||||
description=description,
|
||||
tags=tags or [],
|
||||
icon=icon or "",
|
||||
icon_color=icon_color or "",
|
||||
)
|
||||
|
||||
self._items[eid] = endpoint
|
||||
self._save_item(eid, endpoint)
|
||||
logger.info(f"Created HTTP endpoint: {name} ({eid})")
|
||||
return endpoint
|
||||
|
||||
def update_endpoint(
|
||||
self,
|
||||
endpoint_id: str,
|
||||
name: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
method: Optional[str] = None,
|
||||
auth_token: Optional[str] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout_s: Optional[float] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
icon: Optional[str] = None,
|
||||
icon_color: Optional[str] = None,
|
||||
) -> HTTPEndpoint:
|
||||
existing = self.get(endpoint_id)
|
||||
|
||||
if name is not None and name != existing.name:
|
||||
self._check_name_unique(name)
|
||||
if method is not None and method not in ("GET", "HEAD"):
|
||||
raise ValueError(f"Unsupported method: {method!r}. Use GET or HEAD.")
|
||||
if timeout_s is not None and timeout_s <= 0:
|
||||
raise ValueError("timeout_s must be > 0")
|
||||
|
||||
updated = HTTPEndpoint(
|
||||
id=existing.id,
|
||||
name=name if name is not None else existing.name,
|
||||
created_at=existing.created_at,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
url=url if url is not None else existing.url,
|
||||
method=method if method is not None else existing.method,
|
||||
auth_token=auth_token if auth_token is not None else existing.auth_token,
|
||||
headers=dict(headers) if headers is not None else dict(existing.headers),
|
||||
timeout_s=timeout_s if timeout_s is not None else existing.timeout_s,
|
||||
description=description if description is not None else existing.description,
|
||||
tags=tags if tags is not None else existing.tags,
|
||||
icon=icon if icon is not None else existing.icon,
|
||||
icon_color=icon_color if icon_color is not None else existing.icon_color,
|
||||
)
|
||||
|
||||
self._items[endpoint_id] = updated
|
||||
self._save_item(endpoint_id, updated)
|
||||
logger.info(f"Updated HTTP endpoint: {updated.name} ({endpoint_id})")
|
||||
return updated
|
||||
@@ -9,6 +9,8 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from ledgrab.utils import secret_box
|
||||
|
||||
|
||||
def _parse_common(data: dict) -> dict:
|
||||
"""Extract common fields from a dict, parsing timestamps."""
|
||||
@@ -52,13 +54,16 @@ class MQTTSource:
|
||||
icon_color: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
# Always persist the broker password in encrypted envelope form. If
|
||||
# the field already contains an envelope, ``encrypt()`` is a no-op.
|
||||
stored_password = secret_box.encrypt(self.password) if self.password else ""
|
||||
d = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"broker_host": self.broker_host,
|
||||
"broker_port": self.broker_port,
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
"password": stored_password,
|
||||
"client_id": self.client_id,
|
||||
"base_topic": self.base_topic,
|
||||
"description": self.description,
|
||||
@@ -75,12 +80,17 @@ class MQTTSource:
|
||||
@staticmethod
|
||||
def from_dict(data: dict) -> "MQTTSource":
|
||||
common = _parse_common(data)
|
||||
# Decrypt at load time so consumers see plaintext via ``.password``.
|
||||
# Legacy plaintext rows pass through unchanged — the next save will
|
||||
# write them back in encrypted form.
|
||||
raw_password = data.get("password", "")
|
||||
password = secret_box.decrypt(raw_password) if raw_password else ""
|
||||
return MQTTSource(
|
||||
**common,
|
||||
broker_host=data.get("broker_host", "localhost"),
|
||||
broker_port=int(data.get("broker_port", 1883)),
|
||||
username=data.get("username", ""),
|
||||
password=data.get("password", ""),
|
||||
password=password,
|
||||
client_id=data.get("client_id", "ledgrab"),
|
||||
base_topic=data.get("base_topic", "ledgrab"),
|
||||
icon=data.get("icon", ""),
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import List, Optional
|
||||
from ledgrab.storage.base_sqlite_store import BaseSqliteStore
|
||||
from ledgrab.storage.database import Database
|
||||
from ledgrab.storage.mqtt_source import MQTTSource
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils import get_logger, secret_box
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -20,6 +20,41 @@ class MQTTSourceStore(BaseSqliteStore[MQTTSource]):
|
||||
|
||||
def __init__(self, db: Database):
|
||||
super().__init__(db, MQTTSource.from_dict)
|
||||
self._migrate_plaintext_passwords()
|
||||
|
||||
def _migrate_plaintext_passwords(self) -> None:
|
||||
"""Encrypt any stored MQTT broker passwords still in plaintext at rest.
|
||||
|
||||
Mirrors the HA-token migration: inspect raw DB rows, re-save items
|
||||
that lack the encryption envelope so the next persistence pass
|
||||
writes them in encrypted form.
|
||||
"""
|
||||
migrated = 0
|
||||
try:
|
||||
rows = self._db.load_all(self._table_name)
|
||||
except Exception as exc:
|
||||
logger.error("Could not inspect rows for MQTT password migration: %s", exc)
|
||||
return
|
||||
for row in rows:
|
||||
sid = row.get("id")
|
||||
raw_pw = row.get("password", "") or ""
|
||||
if not sid or not raw_pw:
|
||||
continue
|
||||
if secret_box.is_encrypted(raw_pw):
|
||||
continue
|
||||
source = self._items.get(sid)
|
||||
if source is None:
|
||||
continue
|
||||
try:
|
||||
self._save_item(sid, source)
|
||||
migrated += 1
|
||||
except Exception as exc:
|
||||
logger.error("Failed to migrate MQTT password for %s: %s", sid, exc)
|
||||
if migrated:
|
||||
logger.warning(
|
||||
"MIGRATION: encrypted %d plaintext MQTT broker password(s) at rest.",
|
||||
migrated,
|
||||
)
|
||||
|
||||
# Backward-compatible aliases
|
||||
get_all_sources = BaseSqliteStore.get_all
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user