Compare commits
3 Commits
c1eeefcf06
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 838c95484d | |||
| 14822fb6a0 | |||
| 0c096db639 |
+69
-78
@@ -1,100 +1,91 @@
|
||||
## v0.8.2 (2026-06-08)
|
||||
## v0.9.0 (2026-06-23)
|
||||
|
||||
### User-facing changes
|
||||
A large feature release: a full activity/audit log, two roadmap batches of
|
||||
capture and smart-light improvements, per-pixel control for LIFX/Hue/Nanoleaf,
|
||||
and new outbound integrations (webhooks + Home Assistant MQTT discovery).
|
||||
|
||||
#### Features
|
||||
### Features
|
||||
|
||||
##### WLED native realtime UDP output
|
||||
- New realtime UDP sink speaking WLED's **DRGB / DRGBW / DNRGB** protocols, with automatic revert to the device's prior state when streaming stops ([7728aec](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7728aec))
|
||||
#### Activity Log
|
||||
- Persistent activity/audit log: storage model with migration, recorder with
|
||||
actor context and retention, and event instrumentation across four
|
||||
categories ([1ac4a0f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ac4a0f), [726f39e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/726f39e), [25c613c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/25c613c))
|
||||
- REST API for list / export / settings / clear ([4a09275](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4a09275))
|
||||
- Activity tab with smart filtering, live updates, and export, plus a
|
||||
dashboard widget and settings panel ([9a0137f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9a0137f), [6e1dd21](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e1dd21))
|
||||
|
||||
##### Automatic brightness limiting (ABL) / power budget
|
||||
- Per-LED power budgeting that caps total draw by scaling brightness to a configurable current/PSU limit, preventing brownouts on long strips ([ffee156](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ffee156))
|
||||
#### Per-pixel smart lights
|
||||
- LIFX multizone (SetExtendedColorZones) and Tile per-pixel streaming,
|
||||
auto-detected on connect with single-colour fallback ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
|
||||
- Philips Hue gradient-lightstrip mapping: Entertainment v2 frames keyed by
|
||||
channel id, with a `hue_gradient_mode` toggle ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
|
||||
- Nanoleaf extControl v2 per-panel UDP streaming (`per_panel` mode) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||
|
||||
##### Scene playlists
|
||||
- Scenes can be grouped into **playlists with timed auto-cycling**, so a target can rotate through looks on a schedule ([f71e10e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f71e10e))
|
||||
- Playlist + cycling state is included in the aggregated `/snapshot` response ([abc204c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/abc204c))
|
||||
#### Capture & effects
|
||||
- Linear-light blending and spatio-temporal dithering, opt-in per calibration ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||
- Audio-reactive palette modulation across all 12 procedural effects ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||
- Color-harmony gradient generator (complementary / analogous / triadic / …) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||
|
||||
##### Auto edge-calibration + guided first-run setup wizard
|
||||
- Backend core for **automatic screen-edge calibration** ([0409cd8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0409cd8)), a one-call setup scaffold with an onboarding flag ([9dcd76d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9dcd76d)), and a browser-driven calibration UI ([9550688](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9550688))
|
||||
- A **guided first-run setup wizard** ties it together for new installs ([81b1808](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/81b1808)), with all-provider source discovery and a spatial corner picker ([dd43f38](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd43f38))
|
||||
#### Automations & integrations
|
||||
- Solar sunrise/sunset automation trigger (new `utils/solar.py`) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||
- Outbound webhook automation action (Discord / IFTTT / Zapier / Node-RED),
|
||||
SSRF-gated at save and fire time ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
|
||||
- Home Assistant MQTT auto-discovery: read-only binary sensors per automation,
|
||||
availability via birth/will, with cleanup on disable/delete ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
|
||||
- League of Legends poller wired via a `LoLPollManager` + shared runtime state ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||
- `auth.expose_docs` flag (default off) to view `/docs`, `/redoc`, and
|
||||
`/openapi.json` without a token; all real endpoints stay protected ([126d8f2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/126d8f2))
|
||||
|
||||
##### Region-of-interest (ROI) screen capture
|
||||
- Screen sampling can now be cropped to a **region of interest** instead of the whole display ([ca59546](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ca59546))
|
||||
|
||||
##### Built-in "look" presets
|
||||
- One-click looks: **Cinematic / Vivid / Cozy / Soft / Cool** ([e18d56c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e18d56c))
|
||||
|
||||
##### Weekday + timezone scheduling
|
||||
- The time-of-day automation rule now supports **weekday selection and explicit timezones** ([1ada5ac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ada5ac))
|
||||
|
||||
##### Value sources
|
||||
- New **sandboxed-Jinja template combinator** for composing value sources ([6de61b9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6de61b9)) and optional normalization for magnitude sources ([669ae20](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/669ae20))
|
||||
|
||||
##### Visual graph editor
|
||||
- The editor is now a **full wiring control surface** ([2e51f46](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2e51f46)), and you can **duplicate a selected subgraph** server-side ([15cfb82](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/15cfb82))
|
||||
|
||||
##### Android on-device capture
|
||||
- **System audio playback capture** ([fd62db1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd62db1)), **OS notification capture** via NotificationListenerService ([0be3f83](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0be3f83)), **webcam capture** via Camera2 ([4bf3fe6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4bf3fe6)), and a **foreground-app automation condition** ([1c1bbe2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c1bbe2))
|
||||
|
||||
#### Bug Fixes
|
||||
- **Security:** removed an active **weak default API key** from the shipped config — fresh installs no longer ship with a guessable key. Set your own key on first run ([5686ae5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5686ae5))
|
||||
- Removed a broken legacy `/system/mqtt/settings` route ([fdc9201](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdc9201))
|
||||
- Scene brightness value-source changes now sync to the live processor immediately ([02e2ea3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/02e2ea3))
|
||||
- Wizard hardening: scaffolded targets are registered with the ProcessorManager and the final review step is more robust ([6cd5e05](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6cd5e05))
|
||||
- Installer opens the WebUI only once after "Launch LedGrab" ([05cf121](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05cf121))
|
||||
### Bug Fixes
|
||||
- Pre-release review hardening: solar timezone crash, webhook header CRLF,
|
||||
MQTT topic-prefix injection, thread-safe `get_stats` copy, MQTT discovery
|
||||
lock, `reactive_mode` Literal, and calibration-modal accessibility ([0c096db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0c096db))
|
||||
- Comprehensive review fixes across security, concurrency, performance,
|
||||
Android, and UI ([17dd2e0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17dd2e0))
|
||||
- Activity Log polish: accessible export menu, i18n placeholders, dashboard
|
||||
section reconciliation, column alignment, ticking time, and no spinner
|
||||
flash on instant filtering ([3dd1ac3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3dd1ac3), [ff1ff06](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ff1ff06), [77284e8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/77284e8), [ae74cca](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ae74cca), [077c99c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/077c99c))
|
||||
|
||||
---
|
||||
|
||||
### Development / Internal
|
||||
|
||||
#### Backend / Storage
|
||||
- `clone()` is now gated behind an **opt-in allowlist**, with expanded duplicate-handling tests ([498854f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/498854f))
|
||||
#### CI/Build
|
||||
- Best-effort arm64 multi-arch Docker manifest via QEMU + `docker manifest`
|
||||
(amd64 path untouched) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||
|
||||
#### Frontend
|
||||
- In-progress dashboard customization groundwork ([6180569](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6180569))
|
||||
#### Chores
|
||||
- Activity Log feature plan/subplan scaffold, post-merge cleanup, and context
|
||||
graduated into CLAUDE.md ([1afe7d6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1afe7d6), [e584235](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e584235))
|
||||
|
||||
#### Docs
|
||||
- Actualized README + API reference with embedded screenshots ([12b40e6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/12b40e6)), graph-editor wiring-control roadmap ([d505388](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d505388)), Android audio-capture design notes ([4b2e8fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b2e8fc)); removed stale ANDROID-REVIEW planning docs ([9960f15](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9960f15))
|
||||
|
||||
#### Tests
|
||||
- Large new suites for calibration solver/session (incl. adversarial), setup & scene-playlist routes, playlist engine, and ROI capture. Full suite: **2149 passing, 2 skipped**
|
||||
> Tests: ~180 new unit tests added across the activity log, roadmap features,
|
||||
> and integrations. Release gate green: ruff + tsc + build clean,
|
||||
> **pytest 2739 passed / 2 skipped**.
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>All Commits (31)</summary>
|
||||
<summary>All Commits</summary>
|
||||
|
||||
| Hash | Message | Author |
|
||||
| ---- | ------- | ------ |
|
||||
| [dd43f38](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd43f38) | fix(calibration-wizard): all-provider discovery + spatial corner picker | alexei.dolgolyov |
|
||||
| [6cd5e05](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6cd5e05) | fix(setup): register scaffolded target with ProcessorManager + final-review hardening | alexei.dolgolyov |
|
||||
| [81b1808](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/81b1808) | feat(onboarding): guided first-run setup wizard (phase 4, final) | alexei.dolgolyov |
|
||||
| [abc204c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/abc204c) | feat(snapshot): include scene playlists + cycling state in snapshot | alexei.dolgolyov |
|
||||
| [9550688](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9550688) | feat(calibration): browser-driven auto edge-calibration UI (phase 3) | alexei.dolgolyov |
|
||||
| [9dcd76d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9dcd76d) | feat(setup): one-call setup scaffold + onboarding flag (phase 2) | alexei.dolgolyov |
|
||||
| [0409cd8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0409cd8) | feat(calibration): auto edge-calibration backend core (phase 1) | alexei.dolgolyov |
|
||||
| [6180569](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6180569) | wip(dashboard): in-progress dashboard customization changes | alexei.dolgolyov |
|
||||
| [f71e10e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f71e10e) | feat(scenes): scene playlists with timed auto-cycling | alexei.dolgolyov |
|
||||
| [ca59546](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ca59546) | feat(capture): region-of-interest (ROI) crop for screen sampling | alexei.dolgolyov |
|
||||
| [1ada5ac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ada5ac) | feat(automations): weekday + timezone scheduling for time-of-day rule | alexei.dolgolyov |
|
||||
| [e18d56c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e18d56c) | feat(processing): built-in 'look' presets (Cinematic/Vivid/Cozy/Soft/Cool) | alexei.dolgolyov |
|
||||
| [7728aec](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7728aec) | feat(wled): native realtime UDP output (DRGB/DRGBW/DNRGB) with auto-revert | alexei.dolgolyov |
|
||||
| [ffee156](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ffee156) | feat(targets): automatic brightness limiting (ABL) / per-LED power budget | alexei.dolgolyov |
|
||||
| [02e2ea3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/02e2ea3) | fix(scenes): sync brightness value-source change to live processor | alexei.dolgolyov |
|
||||
| [fdc9201](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdc9201) | fix(api): remove broken legacy /system/mqtt/settings route | alexei.dolgolyov |
|
||||
| [5686ae5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5686ae5) | fix(security): remove active weak default API key from shipped config | alexei.dolgolyov |
|
||||
| [9960f15](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9960f15) | docs(android): remove ANDROID-REVIEW planning/review docs | alexei.dolgolyov |
|
||||
| [1c1bbe2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c1bbe2) | feat(android): foreground-app automation condition | alexei.dolgolyov |
|
||||
| [4bf3fe6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4bf3fe6) | feat(android): on-device webcam capture via Camera2 (AndroidCameraEngine) | alexei.dolgolyov |
|
||||
| [0be3f83](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0be3f83) | feat(android): on-device OS notification capture (NotificationListenerService) | alexei.dolgolyov |
|
||||
| [4b2e8fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b2e8fc) | docs(android): add audio-capture design + missing-functionality review | alexei.dolgolyov |
|
||||
| [fd62db1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd62db1) | feat(audio): Android on-device system playback capture | alexei.dolgolyov |
|
||||
| [669ae20](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/669ae20) | feat(value-sources): optional normalization for magnitude sources | alexei.dolgolyov |
|
||||
| [6de61b9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6de61b9) | feat(value-sources): add sandboxed-Jinja template combinator | alexei.dolgolyov |
|
||||
| [12b40e6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/12b40e6) | docs: actualize README and API reference, embed screenshots | alexei.dolgolyov |
|
||||
| [498854f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/498854f) | refactor(storage): gate clone() behind an opt-in allowlist; expand duplicate tests | alexei.dolgolyov |
|
||||
| [15cfb82](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/15cfb82) | feat(graph): duplicate a selected subgraph server-side | alexei.dolgolyov |
|
||||
| [2e51f46](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2e51f46) | feat(graph): make the visual editor a full wiring control surface | alexei.dolgolyov |
|
||||
| [05cf121](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05cf121) | fix(installer): open WebUI once after "Launch LedGrab" | alexei.dolgolyov |
|
||||
|------|---------|--------|
|
||||
| [0c096db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0c096db) | fix: address pre-release review findings (2026-06-23) | alexei.dolgolyov |
|
||||
| [39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554) | feat: roadmap round two (2026-06-23) — per-pixel smart-lights + integrations | alexei.dolgolyov |
|
||||
| [6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25) | feat: roadmap batch (2026-06-19) — solar/linear-light/dither/nanoleaf + integrations | alexei.dolgolyov |
|
||||
| [126d8f2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/126d8f2) | feat(auth): add auth.expose_docs flag to view API docs without a token | alexei.dolgolyov |
|
||||
| [e584235](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e584235) | chore(activity-log): post-merge cleanup + graduate context to CLAUDE.md | alexei.dolgolyov |
|
||||
| [077c99c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/077c99c) | fix(activity-log): no spinner flash on instant filtering | alexei.dolgolyov |
|
||||
| [ae74cca](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ae74cca) | fix(activity-log): UI polish - accessible export menu, i18n placeholders, zero-result spinner fix | alexei.dolgolyov |
|
||||
| [77284e8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/77284e8) | fix(activity-log): dashboard section reconciliation + activity column alignment | alexei.dolgolyov |
|
||||
| [ff1ff06](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ff1ff06) | fix(activity-log): post-test polish - localize descriptions, dashboard widget, ticking time | alexei.dolgolyov |
|
||||
| [3dd1ac3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3dd1ac3) | fix(activity-log): final-review fixes - crosslink keys + sanitize parity | alexei.dolgolyov |
|
||||
| [6e1dd21](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e1dd21) | feat(activity-log): phase 6 - dashboard widget + settings panel + docs | alexei.dolgolyov |
|
||||
| [9a0137f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9a0137f) | feat(activity-log): phase 5 - Activity tab (smart filtering, live updates, export) | alexei.dolgolyov |
|
||||
| [4a09275](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4a09275) | feat(activity-log): phase 4 - REST API (list/export/settings/clear) | alexei.dolgolyov |
|
||||
| [25c613c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/25c613c) | feat(activity-log): phase 3 - event instrumentation (4 categories) | alexei.dolgolyov |
|
||||
| [726f39e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/726f39e) | feat(activity-log): phase 2 - recorder, actor context, retention, lifecycle | alexei.dolgolyov |
|
||||
| [1ac4a0f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ac4a0f) | feat(activity-log): phase 1 - storage model, migration, repository | alexei.dolgolyov |
|
||||
| [1afe7d6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1afe7d6) | chore(activity-log): scaffold feature plan and phase subplans | alexei.dolgolyov |
|
||||
| [17dd2e0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17dd2e0) | fix: resolve comprehensive review findings (security, concurrency, perf, Android, UI) | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -41,7 +41,7 @@ android {
|
||||
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
|
||||
// sideload updates silently refused to install.
|
||||
versionCode = ledgrabVersionCode
|
||||
versionName = "0.8.2"
|
||||
versionName = "0.9.0"
|
||||
|
||||
// ABI selection. Detect armeabi-v7a wheel presence and opt the
|
||||
// ABI in only when the matching pydantic-core wheel is on disk —
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ledgrab"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
|
||||
authors = [
|
||||
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
||||
|
||||
@@ -134,13 +134,18 @@ def _action_from_schema(s: ActionSchema) -> Action:
|
||||
fire_on = s.fire_on or "activate"
|
||||
if fire_on not in ("activate", "deactivate", "both"):
|
||||
raise ValueError(f"Invalid fire_on: {fire_on}. Must be activate, deactivate or both.")
|
||||
# content_type is emitted verbatim as the outbound Content-Type header — reject
|
||||
# control chars (CR/LF) so it can't be used to inject additional HTTP headers.
|
||||
content_type = (s.content_type or "application/json").strip()
|
||||
if len(content_type) > 128 or any(ord(c) < 0x20 or ord(c) > 0x7E for c in content_type):
|
||||
raise ValueError("Invalid content_type: control or non-ASCII characters are not allowed.")
|
||||
# Raises HTTPException(400) on a blocked/loopback/metadata target.
|
||||
validate_polling_url(url)
|
||||
return WebhookAction(
|
||||
webhook_url=url,
|
||||
method=method,
|
||||
body_template=s.body_template or "",
|
||||
content_type=s.content_type or "application/json",
|
||||
content_type=content_type,
|
||||
fire_on=fire_on,
|
||||
)
|
||||
|
||||
@@ -487,5 +492,9 @@ async def trigger_automation(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
status, errors = await engine.fire_manual_trigger(automation)
|
||||
try:
|
||||
status, errors = await engine.fire_manual_trigger(automation)
|
||||
except Exception as e: # noqa: BLE001 — surface a structured error, never a bare 500
|
||||
logger.error("Manual trigger failed for automation %s: %s", automation_id, e)
|
||||
return AutomationTriggerResponse(status="error", errors=[str(e)])
|
||||
return AutomationTriggerResponse(status=status, errors=errors)
|
||||
|
||||
@@ -111,12 +111,16 @@ ConditionSchema = RuleSchema
|
||||
class ActionSchema(BaseModel):
|
||||
"""A single outbound action fired alongside scene activation/deactivation."""
|
||||
|
||||
action_type: str = Field(description="Action type discriminator (e.g. 'webhook')")
|
||||
action_type: str = Field(
|
||||
max_length=32, description="Action type discriminator (e.g. 'webhook')"
|
||||
)
|
||||
# Webhook action fields
|
||||
webhook_url: str | None = Field(
|
||||
None, max_length=2048, description="Target URL for the webhook action"
|
||||
)
|
||||
method: str | None = Field(None, description="'POST', 'PUT', or 'GET' (for webhook action)")
|
||||
method: str | None = Field(
|
||||
None, max_length=8, description="'POST', 'PUT', or 'GET' (for webhook action)"
|
||||
)
|
||||
body_template: str | None = Field(
|
||||
None,
|
||||
max_length=8192,
|
||||
@@ -126,10 +130,12 @@ class ActionSchema(BaseModel):
|
||||
),
|
||||
)
|
||||
content_type: str | None = Field(
|
||||
None, description="Content-Type header for the webhook body (default application/json)"
|
||||
None,
|
||||
max_length=128,
|
||||
description="Content-Type header for the webhook body (default application/json)",
|
||||
)
|
||||
fire_on: str | None = Field(
|
||||
None, description="'activate', 'deactivate', or 'both' (for webhook action)"
|
||||
None, max_length=16, description="'activate', 'deactivate', or 'both' (for webhook action)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -338,7 +338,9 @@ class EffectCSSCreate(_CSSCreateBase):
|
||||
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
|
||||
audio_reactive: bool | None = Field(None, description="Modulate output by live audio loudness")
|
||||
reactive_audio_source_id: str | None = Field(None, description="AudioSource id for reactivity")
|
||||
reactive_mode: str | None = Field(None, description="brightness | saturation | both")
|
||||
reactive_mode: Literal["brightness", "saturation", "both"] | None = Field(
|
||||
None, description="brightness | saturation | both"
|
||||
)
|
||||
reactive_intensity: Any = Field(default=None, description="Reactive modulation strength (0-1)")
|
||||
|
||||
|
||||
@@ -542,7 +544,9 @@ class EffectCSSUpdate(_CSSUpdateBase):
|
||||
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
|
||||
audio_reactive: bool | None = Field(None, description="Modulate output by live audio loudness")
|
||||
reactive_audio_source_id: str | None = Field(None, description="AudioSource id for reactivity")
|
||||
reactive_mode: str | None = Field(None, description="brightness | saturation | both")
|
||||
reactive_mode: Literal["brightness", "saturation", "both"] | None = Field(
|
||||
None, description="brightness | saturation | both"
|
||||
)
|
||||
reactive_intensity: Any = Field(default=None, description="Reactive modulation strength (0-1)")
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ class MQTTSourceCreate(BaseModel):
|
||||
default=False, description="Publish Home Assistant MQTT auto-discovery configs"
|
||||
)
|
||||
discovery_prefix: str = Field(
|
||||
default="homeassistant", description="HA MQTT discovery prefix (default 'homeassistant')"
|
||||
default="homeassistant",
|
||||
max_length=64,
|
||||
pattern=r"^[A-Za-z0-9_\-/]+$",
|
||||
description="HA MQTT discovery prefix (default 'homeassistant')",
|
||||
)
|
||||
description: str | None = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
@@ -49,7 +52,12 @@ class MQTTSourceUpdate(BaseModel):
|
||||
publish_ha_discovery: bool | None = Field(
|
||||
None, description="Publish Home Assistant MQTT auto-discovery configs"
|
||||
)
|
||||
discovery_prefix: str | None = Field(None, description="HA MQTT discovery prefix")
|
||||
discovery_prefix: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
pattern=r"^[A-Za-z0-9_\-/]+$",
|
||||
description="HA MQTT discovery prefix",
|
||||
)
|
||||
description: str | None = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] | None = None
|
||||
icon: str | None = Field(
|
||||
|
||||
@@ -396,7 +396,7 @@ class LIFXClient(LEDClient):
|
||||
self._protocol.received.clear()
|
||||
self._send(MSG_GET_DEVICE_CHAIN, b"")
|
||||
self._send(MSG_GET_COLOR_ZONES, _build_get_color_zones_payload())
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + 0.6
|
||||
while loop.time() < deadline:
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
@@ -326,7 +326,7 @@ class NanoleafClient(LEDClient):
|
||||
"""Stream per-panel (extControl) when enabled, else average to one HSB state."""
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("NanoleafClient not connected")
|
||||
loop_now = asyncio.get_event_loop().time()
|
||||
loop_now = asyncio.get_running_loop().time()
|
||||
if loop_now < self._next_tx_at:
|
||||
return True
|
||||
|
||||
|
||||
@@ -61,12 +61,22 @@ def record_events(integration_id: str, events: list[GameEvent]) -> None:
|
||||
|
||||
|
||||
def get_stats(integration_id: str) -> dict[str, Any]:
|
||||
"""Get runtime stats for an integration."""
|
||||
"""Get a snapshot of runtime stats for an integration.
|
||||
|
||||
Returns a fresh copy (incl. a copied ``event_counts_by_type``) so the caller
|
||||
can read/iterate it on the event loop while the poll thread keeps mutating
|
||||
the live dict under the lock — otherwise a concurrent insert raises
|
||||
``RuntimeError: dictionary changed size during iteration``.
|
||||
"""
|
||||
with _state_lock:
|
||||
return _integration_stats.get(
|
||||
integration_id,
|
||||
{"event_count": 0, "event_counts_by_type": {}, "last_event_time": None},
|
||||
)
|
||||
stats = _integration_stats.get(integration_id)
|
||||
if stats is None:
|
||||
return {"event_count": 0, "event_counts_by_type": {}, "last_event_time": None}
|
||||
return {
|
||||
"event_count": stats["event_count"],
|
||||
"event_counts_by_type": dict(stats["event_counts_by_type"]),
|
||||
"last_event_time": stats["last_event_time"],
|
||||
}
|
||||
|
||||
|
||||
def cleanup_state(integration_id: str) -> None:
|
||||
|
||||
@@ -37,6 +37,12 @@ class MQTTManager:
|
||||
# Sources for which we hold a discovery acquire() reference.
|
||||
self._discovery_sources: set[str] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
# Serializes the discovery reconcile (ensure/disable) check-then-acquire so
|
||||
# two concurrent sync_discovery() calls for the same source can't both see
|
||||
# "first time" and double-acquire (ref-count leak). Separate from _lock
|
||||
# because ensure/disable call acquire()/release() which take _lock, and
|
||||
# asyncio.Lock is not re-entrant.
|
||||
self._discovery_lock = asyncio.Lock()
|
||||
|
||||
async def acquire(self, source_id: str) -> MQTTRuntime:
|
||||
"""Get or create a runtime for the given MQTT source. Increments ref count."""
|
||||
@@ -142,29 +148,31 @@ class MQTTManager:
|
||||
return
|
||||
if not getattr(source, "publish_ha_discovery", False):
|
||||
return
|
||||
first_time = source_id not in self._discovery_sources
|
||||
if first_time:
|
||||
runtime = await self.acquire(source_id)
|
||||
self._discovery_sources.add(source_id)
|
||||
else:
|
||||
runtime = self.get_runtime(source_id)
|
||||
if runtime is None:
|
||||
return
|
||||
async with self._discovery_lock:
|
||||
first_time = source_id not in self._discovery_sources
|
||||
if first_time:
|
||||
runtime = await self.acquire(source_id)
|
||||
self._discovery_sources.add(source_id)
|
||||
else:
|
||||
runtime = self.get_runtime(source_id)
|
||||
if runtime is None:
|
||||
return
|
||||
await self._make_publisher(runtime, source).publish_all()
|
||||
|
||||
async def disable_discovery(self, source_id: str) -> None:
|
||||
"""Clear a source's discovery configs and drop our runtime reference."""
|
||||
if source_id not in self._discovery_sources:
|
||||
return
|
||||
runtime = self.get_runtime(source_id)
|
||||
if runtime is not None:
|
||||
try:
|
||||
source = self._store.get(source_id)
|
||||
await self._make_publisher(runtime, source).remove_all()
|
||||
except Exception as exc: # noqa: BLE001 — best-effort cleanup
|
||||
logger.warning("HA discovery cleanup failed for %s: %s", source_id, exc)
|
||||
self._discovery_sources.discard(source_id)
|
||||
await self.release(source_id)
|
||||
async with self._discovery_lock:
|
||||
if source_id not in self._discovery_sources:
|
||||
return
|
||||
self._discovery_sources.discard(source_id)
|
||||
runtime = self.get_runtime(source_id)
|
||||
if runtime is not None:
|
||||
try:
|
||||
source = self._store.get(source_id)
|
||||
await self._make_publisher(runtime, source).remove_all()
|
||||
except Exception as exc: # noqa: BLE001 — best-effort cleanup
|
||||
logger.warning("HA discovery cleanup failed for %s: %s", source_id, exc)
|
||||
await self.release(source_id)
|
||||
|
||||
async def sync_discovery(self, source_id: str) -> None:
|
||||
"""Reconcile discovery state after a source is created/updated."""
|
||||
|
||||
@@ -456,10 +456,10 @@ class EffectColorStripStream(ColorStripStream):
|
||||
Quiet audio dims/desaturates toward ``1 - intensity``; loud audio drives
|
||||
full brightness (and a saturation boost up to ``1 + intensity``).
|
||||
"""
|
||||
e = self._audio_tap.energy()
|
||||
k = max(0.0, min(1.0, self.resolve("reactive_intensity", self._reactive_intensity)))
|
||||
if k <= 0.0:
|
||||
return
|
||||
e = self._audio_tap.energy()
|
||||
f = buf.astype(np.float32)
|
||||
if self._reactive_mode in ("brightness", "both"):
|
||||
f *= (1.0 - k) + k * e
|
||||
|
||||
@@ -3277,6 +3277,14 @@
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: box-shadow var(--duration-fast, 120ms) ease,
|
||||
border-color var(--duration-fast, 120ms) ease;
|
||||
}
|
||||
.gradient-harmony-row input[type="color"]:hover,
|
||||
.gradient-harmony-row input[type="color"]:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, var(--border-color));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color, #4c8dff) 28%, transparent);
|
||||
}
|
||||
.gradient-harmony-types {
|
||||
display: flex;
|
||||
@@ -3284,7 +3292,7 @@
|
||||
gap: 6px;
|
||||
}
|
||||
.gradient-harmony-btn {
|
||||
padding: 4px 10px;
|
||||
/* padding inherited from .btn-sm; only the slightly smaller harmony size differs */
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -3296,6 +3304,12 @@
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
/* The toggle's text label — neutralize the global `label` bottom margin so it
|
||||
stays vertically centered against the switch in this flex row. */
|
||||
.calibration-linear-row label[for] {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.calibration-linear-row .input-hint {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
|
||||
+2
@@ -213,6 +213,7 @@ interface Window {
|
||||
cloneAutomation: (...args: any[]) => any;
|
||||
deleteAutomation: (...args: any[]) => any;
|
||||
copyWebhookUrl: (...args: any[]) => any;
|
||||
triggerAutomationNow: (...args: any[]) => any;
|
||||
|
||||
// ─── Scene Presets ───
|
||||
openScenePresetCapture: (...args: any[]) => any;
|
||||
@@ -275,6 +276,7 @@ startTargetOverlay: (...args: any[]) => any;
|
||||
deleteColorStrip: (...args: any[]) => any;
|
||||
onCSSTypeChange: (...args: any[]) => any;
|
||||
onEffectTypeChange: (...args: any[]) => any;
|
||||
onEffectReactiveToggle: (...args: any[]) => any;
|
||||
onCSSClockChange: (...args: any[]) => any;
|
||||
onAnimationTypeChange: (...args: any[]) => any;
|
||||
onDaylightRealTimeChange: (...args: any[]) => any;
|
||||
|
||||
@@ -199,7 +199,7 @@
|
||||
<input type="checkbox" id="cal-linear-blend">
|
||||
<span class="settings-toggle-slider"></span>
|
||||
</label>
|
||||
<span data-i18n="calibration.linear_blend">Linear-light blending</span>
|
||||
<label for="cal-linear-blend" data-i18n="calibration.linear_blend">Linear-light blending</label>
|
||||
<small class="input-hint" data-i18n="calibration.linear_blend.hint">Average border pixels in linear light for perceptually correct, brighter colour mixing.</small>
|
||||
</div>
|
||||
<div class="calibration-linear-row">
|
||||
@@ -207,7 +207,7 @@
|
||||
<input type="checkbox" id="cal-dither">
|
||||
<span class="settings-toggle-slider"></span>
|
||||
</label>
|
||||
<span data-i18n="calibration.dither">Dithering</span>
|
||||
<label for="cal-dither" data-i18n="calibration.dither">Dithering</label>
|
||||
<small class="input-hint" data-i18n="calibration.dither.hint">Spatio-temporal dithering reduces visible banding on smooth gradients.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,11 @@ def utc_offset_hours_for(tz_name: str, when: datetime.datetime | None = None) ->
|
||||
offset = when.replace(tzinfo=None).astimezone(ZoneInfo(tz_name)).utcoffset()
|
||||
if offset is not None:
|
||||
return offset.total_seconds() / 3600.0
|
||||
except ZoneInfoNotFoundError:
|
||||
# ZoneInfo() also raises ValueError (path-traversal / null-byte names) and
|
||||
# OSError (over-long names) for malformed input, not just
|
||||
# ZoneInfoNotFoundError. Catch all three so one bad SolarRule.timezone
|
||||
# can't crash the whole automation evaluation tick.
|
||||
except (ZoneInfoNotFoundError, ValueError, OSError):
|
||||
pass
|
||||
local_offset = when.astimezone().utcoffset()
|
||||
return local_offset.total_seconds() / 3600.0 if local_offset else 0.0
|
||||
|
||||
@@ -19,6 +19,7 @@ from ledgrab.storage.automation import (
|
||||
DisplayStateRule,
|
||||
HomeAssistantRule,
|
||||
HTTPPollRule,
|
||||
ManualTriggerRule,
|
||||
MQTTRule,
|
||||
Rule,
|
||||
SolarRule,
|
||||
@@ -30,6 +31,7 @@ from ledgrab.storage.automation import (
|
||||
|
||||
EXPECTED_RULE_TYPES = {
|
||||
StartupRule,
|
||||
ManualTriggerRule,
|
||||
ApplicationRule,
|
||||
TimeOfDayRule,
|
||||
SolarRule,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Regression tests for the 2026-06-23 pre-release review fixes.
|
||||
|
||||
Covers the roadmap-batch (per-pixel smart-lights + integrations) findings fixed
|
||||
before release:
|
||||
|
||||
* solar timezone offset must not crash on a malformed ``timezone`` string
|
||||
(DoS of the automation evaluation tick / 500 on manual trigger);
|
||||
* the webhook action's ``content_type`` must reject CRLF / control chars
|
||||
(outbound HTTP header injection);
|
||||
* the MQTT ``discovery_prefix`` must reject wildcard / control chars
|
||||
(HA-discovery topic injection);
|
||||
* the effect ``reactive_mode`` must reject unknown values (silent no-op);
|
||||
* game-integration ``get_stats`` must return an independent copy
|
||||
(cross-thread ``dict changed size during iteration``).
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import types
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from ledgrab.api.routes.automations import _action_from_schema
|
||||
from ledgrab.api.schemas.automations import ActionSchema
|
||||
from ledgrab.api.schemas.color_strip_sources import EffectCSSCreate
|
||||
from ledgrab.api.schemas.mqtt import MQTTSourceCreate
|
||||
from ledgrab.core.game_integration import runtime_state
|
||||
from ledgrab.utils.solar import utc_offset_hours_for
|
||||
|
||||
|
||||
# ── solar timezone offset hardening ──────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_tz",
|
||||
[
|
||||
"../../../etc/passwd", # path traversal -> ValueError
|
||||
"foo\x00bar", # embedded null -> ValueError
|
||||
"x" * 5000, # over-long -> OSError on some platforms
|
||||
"Not/A/Real/Zone", # plausible-but-unknown -> ZoneInfoNotFoundError
|
||||
],
|
||||
)
|
||||
def test_solar_offset_does_not_raise_on_malformed_timezone(bad_tz):
|
||||
"""A crafted SolarRule.timezone must fall back, never crash the eval tick."""
|
||||
when = datetime.datetime(2026, 1, 15, 12, 0, 0)
|
||||
offset = utc_offset_hours_for(bad_tz, when)
|
||||
assert isinstance(offset, float)
|
||||
|
||||
|
||||
def test_solar_offset_valid_timezone_still_resolves():
|
||||
when = datetime.datetime(2026, 1, 15, 12, 0, 0) # winter -> EST = -5
|
||||
assert utc_offset_hours_for("America/New_York", when) == pytest.approx(-5.0)
|
||||
|
||||
|
||||
# ── webhook action Content-Type header injection ─────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_ct",
|
||||
[
|
||||
"application/json\r\nX-Injected: evil",
|
||||
"text/plain\nX-Injected: evil",
|
||||
"application/json\x00",
|
||||
"application/jsön", # non-ASCII
|
||||
],
|
||||
)
|
||||
def test_webhook_action_rejects_unsafe_content_type(bad_ct):
|
||||
schema = ActionSchema(
|
||||
action_type="webhook",
|
||||
webhook_url="http://10.0.0.5/hook",
|
||||
method="POST",
|
||||
content_type=bad_ct,
|
||||
fire_on="activate",
|
||||
)
|
||||
with pytest.raises(ValueError, match="content_type"):
|
||||
_action_from_schema(schema)
|
||||
|
||||
|
||||
def test_webhook_action_accepts_normal_content_type():
|
||||
schema = ActionSchema(
|
||||
action_type="webhook",
|
||||
webhook_url="http://10.0.0.5/hook", # LAN IP is allowed by SSRF policy
|
||||
method="POST",
|
||||
content_type="application/json; charset=utf-8",
|
||||
fire_on="activate",
|
||||
)
|
||||
action = _action_from_schema(schema)
|
||||
assert action.content_type == "application/json; charset=utf-8"
|
||||
|
||||
|
||||
# ── MQTT HA-discovery topic injection ────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_prefix",
|
||||
[
|
||||
"homeassistant/+/evil", # MQTT wildcard
|
||||
"homeassistant/#", # MQTT wildcard
|
||||
"home\nassistant", # control char
|
||||
"x" * 65, # over max_length
|
||||
],
|
||||
)
|
||||
def test_mqtt_discovery_prefix_rejects_unsafe_value(bad_prefix):
|
||||
with pytest.raises(ValidationError):
|
||||
MQTTSourceCreate(name="src", broker_host="broker.local", discovery_prefix=bad_prefix)
|
||||
|
||||
|
||||
def test_mqtt_discovery_prefix_defaults_to_homeassistant():
|
||||
src = MQTTSourceCreate(name="src", broker_host="broker.local")
|
||||
assert src.discovery_prefix == "homeassistant"
|
||||
|
||||
|
||||
# ── effect reactive_mode validation ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_reactive_mode_rejects_unknown_value():
|
||||
with pytest.raises(ValidationError):
|
||||
EffectCSSCreate(name="fx", reactive_mode="invalid")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["brightness", "saturation", "both"])
|
||||
def test_reactive_mode_accepts_known_values(mode):
|
||||
src = EffectCSSCreate(name="fx", reactive_mode=mode)
|
||||
assert src.reactive_mode == mode
|
||||
|
||||
|
||||
# ── game-integration get_stats returns an independent snapshot ───────────────
|
||||
|
||||
|
||||
def test_get_stats_returns_independent_copy():
|
||||
integration_id = "test-int-copy"
|
||||
runtime_state.cleanup_state(integration_id)
|
||||
try:
|
||||
event = types.SimpleNamespace(event_type="kill", timestamp="2026-06-23T00:00:00Z")
|
||||
runtime_state.record_events(integration_id, [event])
|
||||
|
||||
snapshot = runtime_state.get_stats(integration_id)
|
||||
assert snapshot["event_count"] == 1
|
||||
assert snapshot["event_counts_by_type"] == {"kill": 1}
|
||||
|
||||
# Mutating the returned snapshot must not corrupt the live state.
|
||||
snapshot["event_counts_by_type"]["kill"] = 999
|
||||
snapshot["event_counts_by_type"]["injected"] = 1
|
||||
|
||||
fresh = runtime_state.get_stats(integration_id)
|
||||
assert fresh["event_counts_by_type"] == {"kill": 1}
|
||||
assert "injected" not in fresh["event_counts_by_type"]
|
||||
finally:
|
||||
runtime_state.cleanup_state(integration_id)
|
||||
Reference in New Issue
Block a user