ruff --select UP007,UP045 --fix converted ~1760 sites across the
backend: `Optional[T]` → `T | None`, `Union[X, Y]` → `X | Y`. The
remaining module-level alias targets that ruff conservatively skips
(BindableFloatInput, ColorList, DeviceConfig) were converted by hand
earlier in the pass. black -formatted the result so the wider unions
fit cleanly under the 100-char line budget.
pyproject.toml now sets [tool.ruff.lint] extend-select = ["UP007",
"UP045"] so future legacy imports fire CI on every push. The
pre-commit ruff hook was bumped from v0.8.0 -> v0.15.12 to recognise
UP045 (split off from UP007 in v0.13).
The 11 except Exception sites around websocket.send_json and
websocket.close are now except _WS_SEND_BENIGN_EXC — a narrow tuple of
WebSocketDisconnect, RuntimeError, ConnectionError, OSError. Real
programming errors (AttributeError, TypeError) no longer silently
disappear inside the handshake path. The receive_text branch grows a
narrow `(RuntimeError, ConnectionError, OSError)` case plus a final
`except Exception: logger.exception(...)` catch-all so genuinely
unexpected error shapes are recorded with a stack trace instead of
being swallowed.
processed_stream lifts the 30-iteration filter recheck cadence to
_FILTER_RECHECK_EVERY_N_FRAMES with a comment explaining the 30 fps
trade-off. wled_target_processor lifts SKIP_REPOLL, the diagnostics
interval, and the CSPT recheck cadence to module-level
_SKIP_REPOLL_SLEEP_SECONDS, _DIAGNOSTICS_REPORT_INTERVAL_SECONDS, and
_CSPT_RECHECK_EVERY_N_ITERATIONS. Tests can monkeypatch them now.
Every IconSelect caller was audited: each builds item.icon from a
constant ICON_* literal, a lookup-table getter, or
renderDeviceIcon(stored_id) — none of which embed user input today.
The new sanitiseIcon() helper is the belt-and-braces guard for a
future caller that forgets the trusted-SVG contract: reject icon
strings containing <script>/<iframe>/<embed>/<object>/javascript:/on*=
markers, warn to the console, and fall back to empty so the cell still
renders the (escaped) label + desc.
TestWLEDSchemeInference in test_devices_routes covers the POST/PUT
create-and-update flow with a stubbed WLED provider so the
infer_http_scheme integration hop has end-to-end coverage instead of
just the unit tests.
test_url_scheme grows public IPv6 (Cloudflare / Google / Quad9 DNS),
bracketed-form, and ULA cases. Adds an explicit pin for the Python
ipaddress documentation-prefix quirk (2001:db8::/32 is is_private,
so it routes to http:// even though some audits colloquially call it
"public").
When a PATCH omits `url` (rename / icon-only edit), normalized_url
arrived at the processor as None and the manager kept whatever it had
cached — or refused to re-sync if it had nothing. Fall back to
existing.url so the processor is always told the current address.
Surfaced by the production-review backlog.
TODO.md grows the device-support follow-up roadmap. CLAUDE.md trims a
stale section. en/ru/zh locales add the strings used by the new
HTTP-endpoint editor, MiniSelect labels, automations expansion, and
value-source kinds. Ru/zh parity for the older keys is tracked
separately in REVIEW_TODO.md.
events-ws gains an inbound-event allowlist matching the new server-side
allowlist; test_events_ws_parity pins the two lists in sync.
state + storage modules and the streams / integrations /
z2m-light-targets / streams-*-templates editors absorb the
closeIfPristine guard alongside small UX fixes. css-editor template
picks up the new MiniSelect markup for the filter-kind picker.
Bundle the remaining backend touch-ups that the production review
landed individually as small surgical edits across many modules:
- MQTT runtime: fire-and-forget task tracking + drain resilience.
- mqtt_source + store + storage/color_strip_source: secret_box
encryption for credentials with auto-migration of plaintext fields.
- devices/discovery_watcher: task tracking on watcher start/stop.
- devices/wled_client + wled_provider: URL scheme inference helper
applied at the create/update boundary so bare hostnames stay valid.
- core/capture/screen_capture: hardened error paths.
- core/processing (mapped/processed/processor_manager/video/wled_target):
smaller follow-throughs from the registry refactor that landed
earlier on the branch.
- utils/safe_source + utils/file_ops + utils/__init__: shared URL +
IP classification helpers + larger streaming upload size caps.
- api/auth: WebSocket Origin allow-list + /docs auth-gate.
- api/dependencies: register the new HTTP-endpoint store.
- api/routes (assets, backup, webhooks): streaming-upload caps +
asyncio.gather return_exceptions on broadcast loops.
- tests/test_api + tests/e2e/test_backup_flow: cover the new caps and
the Origin allow-list.
update_service grows explicit URL validation on the redirect chain so a
hostile mirror can't bounce the updater to a private IP. restart.ps1
gets stricter argument handling and clearer log lines.
default_config.yaml exposes the new toggles. test_system_routes pins
the new behaviour.
The "static" source kind always rendered a SINGLE color and the name
confused new code paths. Rename the module + kind to "single". Storage
keeps backward-compatible serialisation. Frontend color-strip cards /
gradient / index / test modules and the affected tests follow the new
name.
Storage model + Pydantic schema + route gain fields for the value-source
kinds introduced by the per-type factory refactor. Frontend editor adds
inputs for them and the modal template grows new field rows.
Storage model + Pydantic schema + route surface gain the new rule
shapes the engine already supports. Frontend automations editor grows
the matching inputs. New core/test_automation_engine.py pins the
dispatch table rules behind ~285 lines of unit coverage.
Modal gains closeIfPristine(entityId): when editing an existing entity
and no tracked field has changed, the helper force-closes the modal
silently and returns true so the caller can skip the PUT and the
misleading "updated" toast. Each editor's save handler now early-returns
on the no-op edit path: advanced-calibration, assets,
audio-processing-templates, audio-sources, calibration, devices,
game-integration, ha-light-targets, home-assistant-sources,
mqtt-sources, pattern-templates, scene-presets, sync-clocks, targets,
weather-sources.
MiniSelect replaces the forbidden plain <select> in editors where the
option list isn't large enough to justify the full IconSelect grid. It
shares the IconSelect look-and-feel (chip + dropdown panel, keyboard
nav, search). IconSelect grows an explicit HTML-escape for item labels
and keeps `item.icon` documented as a trusted-SVG sink for callers
that build the icon string from constants. global-types.d.ts gives
existing window.* accesses real types so feature modules can stop
falling back to `(window as any)`. The modal.css additions style the
two selectors and the new dropdown panels.
New output kind that POSTs the current strip frame to a user-configured
HTTP endpoint, alongside WLED / MQTT / Hue. Stack mirrors the existing
output-target shape end-to-end: storage model + store, FastAPI router +
Pydantic schemas, JS feature module + modal template, router wiring in
api/__init__.py and the modal include in index.html. Tests cover both
the routes and the store.
``_target_to_response`` in ``api/routes/output_targets.py`` used to be
an isinstance ladder over the three OutputTarget subclasses with a
silent fallback that fabricated a ``LedOutputTargetResponse`` for
unknown types (audit finding H3). The fallback masked exactly the
kind of bug we hit on the CSS side in Phase 1.1: a new target subclass
slipped past the ladder and got mis-shaped on the wire.
Replace the ladder with a ``_TARGET_RESPONSE_BUILDERS`` dict keyed by
the concrete subclass plus an import-time
``_assert_target_response_coverage()`` that requires the registry to
exactly match ``{WledOutputTarget, HALightOutputTarget,
Z2MLightOutputTarget}``. ``_target_to_response`` now raises
``RuntimeError`` instead of silently fabricating a LED response for an
unknown subclass — coverage is asserted at import so this branch is
unreachable in normal operation.
Tests: 5 new regression tests cover bijection between expected classes
and registered builders, callable shape, the rogue-target-raises
contract, and missing/extra entry rejection in the assertion. 24
existing output-target tests stay green; ruff clean.
Two HIGH issues surfaced by review of 3b8f00e:
1. ``_build_game_event`` was newly succeeding where the old store
raised ``ValueError("Invalid source type: game_event")``. The
coverage-assertion-symmetry comment was honest about it being
a path that didn't exist before, but silent broadening of the
create contract is a real behaviour delta — any internal caller
that previously caught the error would now succeed.
Make ``_build_game_event`` raise NotImplementedError. The
coverage assertion still passes (the entry exists), but the
historical "you can't create game_event sources through the
store" contract is preserved. game_event instances continue to
be wired up by the game-integration setup path.
2. The new ``create_source`` ran ``_check_name_unique`` BEFORE
``build_source``. When both ``source_type`` is invalid AND
``name`` collides with an existing source, the old code raised
``"Invalid source type: …"`` first; the new code raised the
name-collision error. Swap the order: build first (which
validates source_type), then check name uniqueness, then
persist. Bonus: a uuid is no longer minted for a source we end
up rejecting on type.
New test pins the game_event NotImplementedError so a future
refactor doesn't accidentally re-open the create path.
38 value-source-store + factory tests stay green; ruff clean.
ValueSourceStore.create_source used to be a ~260-line if/elif chain
over 14 source_type strings; update_source did the same dance again
with 14 isinstance branches (audit finding C7 store-side). Each
branch duplicated the common-fields scaffold and the per-type
defaulting + validation logic.
Lift each per-type create / update body into a free function in a
new ``storage.value_source_factories`` module:
* ``CREATE_BUILDERS[source_type]`` — owns defaulting + per-type
validation (HA needs ha_source_id + entity_id; gradient_map
needs value_source_id; system_metrics validates against
VALID_SYSTEM_METRICS; http rejects interval_s < 1; the two
adaptive_* sub-modes route to the same AdaptiveValueSource
class with different source_type discriminators).
* ``UPDATE_APPLIERS[source_type]`` — mirrors the above on the
update side; ``resolve_ref`` is applied to cross-entity
references so empty-string clears keep working.
* ``build_source(...)`` / ``apply_update(source, **kwargs)`` are
the public entry points the store calls.
* ``_assert_factory_coverage()`` runs at module import and
requires BOTH registries to match storage's _VALUE_SOURCE_MAP
exactly.
The store's ``create_source`` shrinks from ~260 lines to ~25;
``update_source`` from ~200 lines to ~40.
Tests: 14 new tests cover registry coverage in both directions
plus drift assertions, representative builder paths (static /
adaptive_time / adaptive_scene / ha_entity / http / unknown),
the AdaptiveValueSource dual-source-type discriminator, and
several applier paths including ``**_`` swallowing unknown kwargs
and HTTP zero-interval rejection. 47 existing value-source store
tests stay green; 769 storage / core / api tests in aggregate.
Ruff clean.
types.ts is 1159 lines of kitchen-sink discriminated-union definitions
(audit finding H6) shared across ~30 frontend modules. Splitting the
whole file in one pass would need careful per-group TypeScript wrangling
and verification across every entity shape; this commit lands the
first slice as a proof of pattern.
What changed
------------
* New ``static/js/types/bindable.ts`` owns ``BindableFloat`` /
``BindableColor`` plus their four accessor helpers
(``bindableValue``, ``bindableSourceId``, ``bindableColor``,
``bindableColorSourceId``).
* ``static/js/types.ts`` keeps every interface and union shape that
references those primitives, but the primitives themselves now come
from the new file. Re-export keeps every existing ``import { ... }
from '../types.ts'`` site working unchanged.
Why this slice first
--------------------
Bindable types and helpers are the most heavily-imported piece of
types.ts (~30 modules already use ``bindable*`` helpers) and they have
zero downstream dependencies — they don't reference any other type
group. That makes them the safest extraction and the cleanest
demonstration of the barrel-re-export pattern the remaining groups
(devices, sources, integrations, automations, templates, …) will
follow in a follow-up sprint.
Verification
------------
* ``npx tsc --noEmit`` clean (no compile errors anywhere in the
frontend tree).
* ``npm run build`` clean (esbuild bundle and CSS bundle produced
without warnings).
The remaining ~1130 lines of types.ts plus the C8/C9/C10 god-module
splits (value-sources.ts, streams.ts, graph-editor.ts) need a
dedicated frontend session with typescript-reviewer + manual UI
testing — deferring those rather than half-finishing them here.
SystemMetricsValueStream used to dispatch on its ``self._metric`` string
across three independent if/elif chains (audit finding M5):
* priming in ``start()`` (cpu_percent seed, initial network counter);
* raw reading in ``_read_metric_psutil`` plus ``_read_metric_fallback``;
* normalisation in ``_normalize`` (percent / min-max range / max-rate).
Adding a new metric meant editing all three chains plus the Android
fallback — and forgetting one branch made the metric silently return 0.
Lift each per-metric concern into a free function and register them as a
``MetricSpec(name, read_psutil, read_fallback, normalize, prime)`` in a
new ``core.processing.metric_readers`` module. Shared normalisers
(``_norm_percent`` / ``_norm_range`` / ``_norm_rate`` / ``_zero``) live
once. The stream's ``start()`` / ``_read_metric()`` / ``_normalize()``
collapse to a single registry lookup + delegation.
The stream still owns its mutable state (``_disk_path``,
``_sensor_label``, ``_gpu_unavailable``, ``_prev_net_bytes``,
``_prev_net_time``, etc.) — readers operate on the stream by
parameter, not by inheritance, so the kitchen-sink class shrinks by
~140 lines without losing the per-stream cadence bookkeeping. Each
spec function's docstring documents which fields it reads or mutates.
Tests: 16 new tests cover the 10-metric coverage set, callable shape
of every spec field, the three normaliser primitives' clamping +
divide-by-zero behaviour, prime-hook presence (only the three metrics
that need a baseline: cpu_load + network_rx + network_tx), and
fallback-path expectations (desktop-only sensors -> _zero, cpu/ram ->
real MetricsProvider).
754 existing core / storage / api tests stay green; ruff clean.
AutomationEngine._evaluate_rule used to rebuild a 9-entry dispatch
dict on EVERY rule evaluation (audit finding H2). Unknown rule types
silently returned False — adding a new Rule subclass without an entry
just made it inert forever.
Refactor:
* Per-rule-type bodies are now ``_handle_<kind>(self, rule, ctx)``
methods on AutomationEngine.
* A ``_RuleEvalContext`` frozen dataclass bundles all the
cross-cutting state (running_procs, topmost_proc,
topmost_fullscreen, fullscreen_procs, idle_seconds, display_state)
so adding a new handler does not require widening
``_evaluate_rule``'s parameter list.
* ``AutomationEngine._RULE_HANDLERS`` is bound once at module-import
time after the class is defined.
* ``_assert_rule_handler_coverage()`` runs at import: every Rule
subclass imported by the module must have an entry, and entries
keyed by an unknown class are also rejected.
Unknown-type fallback now logs a warning instead of silently returning
False, so a future Rule subclass missing from the registry surfaces in
operator logs rather than just behaving as if the automation were off.
The pure storage layer (storage/automation.py) is untouched — the
handler bodies stay on the engine where the cross-layer dependencies
(MQTT runtime, HA manager, HTTP endpoint store, webhook state) live.
Tests: 4 new tests cover the rule-type/handler bijection, callable
shape, missing-entry rejection, and unknown-class rejection. 44
existing automation engine tests stay green; ruff clean.
PixelMapper and AdvancedPixelMapper in calibration.py used to carry
byte-for-byte copies of two ~80-line numpy kernels (audit finding M4):
* the vectorised average-colour-per-LED path with its cumsum + take
scratch-buffer dance; and
* the per-LED fallback loop for median / dominant colour modes.
Lift both into a new ``core.capture.edge_interpolation`` module exposing
``average_edge_to_leds(edge_pixels, edge_name, led_count, cache,
cache_key)`` and ``fallback_edge_to_leds(edge_pixels, edge_name,
led_count, calc_color)``. The cache parameter is the caller-owned dict
(``self._edge_cache``) so allocations still happen once per
(edge_len, led_count) signature — the difference is that the
boundary-builder, the buffer set, and the inner numpy ops live in
exactly one place.
PixelMapper keys its cache by edge name (``"top"`` / ``"left"`` etc.);
AdvancedPixelMapper keys by line-index int (same dict, no collision).
Both mappers' ``_map_edge_average`` / ``_map_edge_fallback`` shrink to
single delegating lines.
Tests: 9 new kernel-level tests cover uint8 dtype + shape, the cache
reuse / rebuild contract, independent cache keying, a gradient input
producing a monotonic output, the calc_color callable contract for the
fallback path, and segment-position tracking for both axes. 30
existing calibration tests stay green; ruff clean.
EffectColorStripStream._animate_loop used to rebuild a 12-entry dict
``renderers = {"fire": self._render_fire, ...}`` on every frame, then
look up ``renderers.get(self._effect_type, self._render_fire)``. Two
audit smells (H1) at once: per-frame dict-rebuild churn and a silent
fallback to fire whenever ``self._effect_type`` was a typo or any
``_render_*`` method got renamed without updating the dict.
Fix:
* ``@_effect_renderer("fire")`` stamps an attribute on the unbound
method.
* ``@_collect_effect_renderers`` (applied to the class) walks
members at class-creation, gathers the marked ones into
``cls._RENDERERS``, and raises ``RuntimeError`` on duplicate
registration.
The loop now reads ``type(self)._RENDERERS`` once and calls the
unbound method with explicit ``self``. An unknown ``_effect_type``
logs a warning and skips the frame (sleep one frame_time) instead of
silently rendering fire — louder failure mode without crashing the
animation thread.
Tests: 5 new tests cover the 12-effect coverage set, callable shape,
class-level (not per-instance) dict identity, duplicate-name
rejection, and the marker stamp contract.
343 existing processing / storage / API tests stay green; ruff clean.
HALightTargetProcessor and Z2MLightTargetProcessor used to carry
character-for-character identical _swap_color_source method bodies
(audit finding C5) — only the log prefix differed. Extract the body
into a free function ``swap_color_source(processor, new_kind,
new_color_vs_id, *, log_label)`` in a new ``light_target_helpers``
module. Each processor's _swap_color_source now delegates to the helper
and then clears its per-entity history (``_previous_colors`` /
``_previous_on``) — that bit stays on the processor because it's per-
target state, not colour-source state.
Scope deliberately narrower than the full BaseLightTargetProcessor ABC
the audit gestured at: the 76 read sites for the per-processor colour
state across the two files made a full state-composition refactor too
risky for the live LED control loop. The free-function helper is the
minimum-blast-radius way to delete the duplication while leaving WLED
(which has no value-stream-vs-CSS dispatch) untouched.
The helper standardises both warning messages on HA's original wording
("failed to acquire color VS stream" / "failed to re-acquire CSS
stream") so existing log alerts/grep patterns keep working.
A LightTargetSwapState Protocol under TYPE_CHECKING documents the
expected processor surface; no runtime enforcement (acceptable trade-
off vs a 76-site touchpoint).
Tests: 7 new tests cover the release+acquire ordering, the not-running
no-op path, the manager-error-swallowing behaviour, the empty-id
short-circuit, and the missing-manager (TargetContext(None, None))
fallback. 354 existing storage + API + e2e + processing tests stay
green; ruff clean.
Two CRITICAL data-safety bugs from the architecture audit and the two
worst parallel-change problems are fixed in one coherent pass.
Audit findings addressed:
- C2 silent CSS response fallback. The previous _RESPONSE_MAP fell
through to a fabricated PictureCSSResponse whenever a source
class lacked an entry; in particular game_event sources were
silently mis-shaped. Now: GameEventCSSResponse/Create/Update
schemas exist, _RESPONSE_MAP is re-keyed by source_type string,
an import-time _assert_response_map_coverage() requires symmetric
agreement with storage._SOURCE_TYPE_MAP, and the runtime path
raises instead of fabricating a response.
- C11 string-replace JSON migration. ColorStripStore used
blob.replace('"source_type": "static"', '"source_type":
"single_color"') which can corrupt unrelated substrings (e.g.
an animation type named "static_wave") and provides no audit,
no transaction, no idempotency. Replaced with
storage.data_migrations.MigrationRunner backed by a
data_migrations audit table. Each migration runs inside one
db.transaction() that covers the applied-check, the apply(),
and the audit-INSERT — partial failures roll back atomically.
StaticToSingleColorMigration parses each row with json.loads
and mutates only the source_type field. Frozen-write databases
skip with a warning.
- C3+C4 color-strip stream dispatch. The 7-branch elif in
ColorStripStreamManager.acquire() and the duplicate one in
ws_stream._create_stream() now share a single STREAM_BUILDERS
registry in core.processing.color_strip_kinds, keyed by
source.source_type. Both call sites populate a StreamDeps bag
and delegate to build_stream(). _assert_stream_kind_coverage()
asserts at import that STREAM_BUILDERS plus SHARABLE_KINDS
partitions storage._SOURCE_TYPE_MAP. ws_stream's preview path
wraps each FastAPI-DI getter in _safe() so non-audio previews
no longer crash when audio/CSPT stores are not wired.
- C6+C7 value stream dispatch. The 14-branch isinstance ladder in
ValueStreamManager._create_stream and its silent
StaticValueStream(value=1.0) fallback are replaced by
core.processing.value_kinds.STREAM_BUILDERS, keyed by
source_type string (so AdaptiveValueSource's adaptive_time and
adaptive_scene route to different builders correctly). The
manager retains only the SyncClockRuntime pre-acquisition step
for animated_color (kinds needing this are listed explicitly
in NEEDS_CLOCK_RUNTIME). Symmetric coverage assertion plus a
separate assertion that NEEDS_CLOCK_RUNTIME is a subset of the
registry.
Bundled in: the static->single_color rename plus the HTTPValueStream
/ http_endpoint introduction that were already in flight on this
branch share these files; the registry refactor naturally absorbs
both via the new "single_color" / "static" alias entries and the
_build_http builder.
Tests: 26 new tests cover response-map coverage drift, migration
runner audit-table mechanics + transactional rollback +
frozen-write skip, and the two stream-builder registries. 343
existing storage / API / e2e tests stay green. Ruff clean.
Two bugs caused user data ('G502' target's color-strip ref, etc.) to
revert after PC restart while persisting fine across normal app
restarts:
1. SQLite was in WAL mode with synchronous=NORMAL and Database.close()
was never called. On graceful Python exit the sqlite3 finalizer
checkpoints the WAL, but on an unclean PC shutdown (power loss,
forced reboot, or Windows force-terminating pythonw.exe) the WAL
stayed in OS cache, never reached disk, and the next boot rolled the
DB back to the last checkpoint -- losing recent edits.
2. Nothing handled WM_QUERYENDSESSION / WM_ENDSESSION, so on PC
shutdown Windows force-killed pythonw.exe after ~5s and the FastAPI
lifespan never ran. The 'stop_targets' setting was silently ignored
and devices were left at their last frame.
Changes:
- Database: PRAGMA synchronous=FULL + wal_autocheckpoint=100, plus an
explicit wal_checkpoint(TRUNCATE) inside Database.close().
- New utils/win_shutdown.py: hidden top-level window in a daemon thread
with a ctypes WindowProc that catches WM_QUERYENDSESSION (calls
ShutdownBlockReasonCreate to extend Windows' 5s hung-app timeout up
to the ~20s GUI ceiling), fires the shutdown callback, then waits in
WM_ENDSESSION on a completion event before returning. Also raises
the process shutdown priority via SetProcessShutdownParameters. All
Win32 argtypes/restypes are bound once at import to avoid LPARAM
overflow on x64.
- New shutdown_state.py: leaf module owning the cross-thread Event so
__main__ does not import the heavy ledgrab.main at startup.
- main.py lifespan: per-step asyncio.wait_for budgets (8s for
processor_manager.stop_all, 1.5s each for HA/MQTT, etc.) so a hung
device cannot starve the DB checkpoint, then db.close() and
shutdown_complete.set() always run.
- __main__.py: install the Windows shutdown guard before tray start;
install SIGINT/SIGTERM/SIGBREAK handlers only on the tray path
(uvicorn overwrites them on no-tray); raise server_thread.join to 20s.
- Tests cover WM_QUERYENDSESSION (fires callback, returns TRUE,
idempotent), WM_ENDSESSION (waits on event, times out cleanly,
cancel-path returns instantly), signal handler installation, and
that main and shutdown_state share the same Event instance.
The MODIFIED hint in the Customize Dashboard panel was driven by
`presetActive`, recomputed on every save/load via strict deep-equal
against each preset. Any drift between a saved layout and the current
defaults — older app versions that hadn't yet had some new perf cells
added, prior buggy merges that appended new registry keys to the end
of perfCells, or stale `visible` values from intermediate dev builds —
left `presetActive` undefined forever and pinned the panel in MODIFIED
state for users who had not actually edited anything.
Split the two concerns:
- `presetActive` keeps driving the chip highlight (recomputed). When
the layout happens to match a preset exactly the chip lights up.
- New `userModified` boolean drives the MODIFIED indicator. Set to true
only on actual edits through the panel (visibility / density /
ordering / select changes) and on JSON import; cleared by applying a
preset and by Reset.
Legacy saves without the field load as `userModified: false` so the
indicator no longer fires retroactively on data the user never
touched. Also tighten `_mergeWithDefaults` so newly-added registry
keys land at their canonical positions (subsequence detection) when
the saved order is consistent with defaults, which keeps the chip
highlight stable across upgrades.
The inline transport-uptime ticker only repainted on its 1 s setInterval,
so the field could sit on - for up to ~1 s after page load (and much
longer if init's first /health response landed between ticks - the next
seed then had to wait for the 10 s connection-monitor poll). Dispatch a
serverUptimeChanged DOM event when window.__serverUptime is seeded and
let the inline IIFE re-render on receipt, so the value appears as soon
as the response arrives.
Closes the issues surfaced by the pre-merge code review of the
expand-device-support branch.
CRITICAL #2 -- update_device double-encrypts secrets in memory.
storage/device_store.py round-tripped through device.to_dict() which
encrypts hue_username / hue_client_key / ble_govee_key / nanoleaf_token
via _enc(), but Device.__init__ does not decrypt. The cached
self._items[device_id] thus held ciphertext where plaintext belonged,
breaking runtime auth for paired devices on any update -- even an
innocuous rename. Sourcing kwargs from vars(device) directly avoids
the round-trip. Regression tests cover Nanoleaf and Hue.
HIGH #3 -- secrets leaked in GET /api/v1/devices response.
DeviceResponse previously returned nanoleaf_token / hue_username /
hue_client_key in plaintext (decrypted server-side from storage),
defeating the encryption-at-rest. Replaced with nanoleaf_paired and
hue_paired booleans. ble_govee_key intentionally stays -- it's a
user-managed value pasted from a third-party tool, must remain visible
for edit. Frontend types.ts + the one nanoleaf_token reader updated to
the boolean.
HIGH #4 -- SSRF surface. validate_lan_host() added to net_classify.py;
called from each new driver's validate_device (DDP / Yeelight / WiZ /
LIFX / Govee / OPC / Nanoleaf) and from pair_device. Rejects literal
public IPs with a descriptive ValueError; non-IP hostnames pass
through (mDNS labels, bare hostnames). RFC6890 ranges (documentation,
former class E) are accepted as LAN-like since Python's
ipaddress.is_private treats them so -- correct policy for LedGrab.
HIGH #5 -- decrypt failure deletes the device row. _dec() now catches
the exception, logs an error, and returns "" instead of propagating.
Without the fix, a regenerated data/.secret_key would silently make
every Hue / Nanoleaf / BLE-Govee device disappear from the device list
on next startup. Regression test asserts a corrupt envelope leaves the
device hydratable.
HIGH #6 -- update_device route does not rstrip("/") for non-WLED.
Moved the trim before the WLED-specific scheme inference so every
device type gets consistent URL normalization between create and
update.
MEDIUM #7 -- Govee discovery port 4002 collision. Added a lazily-
initialized module-level asyncio.Lock that serializes concurrent
discover_govee_devices() calls; the previous behavior had the second
parallel scan silently return [] when the first still held port 4002.
Error message also clarified to mention another Govee tool.
MEDIUM #8 -- Nanoleaf discover() leaked browser tasks on cancellation.
Moved the browser cancel loop into the finally block so an interrupted
mDNS scan still tears them down.
MEDIUM #9 -- pair endpoint logged user-supplied URL with exc_info=True.
Added _sanitize_url_for_log() that strips userinfo + fragment, and
demoted the log from exc_info to type(exc).__name__ + str(exc) so a
hostile receiver's response body can't end up in the log file.
LOW -- Nanoleaf was the only client without a .port property. Added
one (returns NANOLEAF_PORT, fixed) for cross-driver symmetry.
LOW -- no end-to-end pair-then-create coverage. Added
TestPairThenCreateFlow.test_pair_then_create_persists_encrypted_token
which exercises the full path: POST /api/v1/devices/pair returned
fields, store.create_device, then asserts (a) in-memory plaintext,
(b) to_config() plaintext, (c) persisted ciphertext, (d) API response
strip + paired-boolean.
Tests: 1379 pass (was 1358 -- 21 new regression tests added).
ruff clean. TypeScript clean.
The DDP commit (8f1140a) added imports of infer_http_scheme into
api/routes/devices.py but missed bringing in the module itself --
url_scheme.py and its net_classify.py dependency were in the working
tree as untracked files only. On a clean checkout the FastAPI app
fails to start with ModuleNotFoundError.
Caught by the pre-merge code review. The 1358 passing tests only
worked because the local working tree happens to have the files.
This commit adds:
- ledgrab.utils.url_scheme: infer_http_scheme() for LAN-vs-public WLED
URL scheme inference
- ledgrab.utils.net_classify: HostCategory enum + classify_ip() +
is_blocked_for_ssrf() + is_local_for_http_default() + is_loopback().
Single source of truth for IP categorisation used by safe_source
(SSRF), url_scheme (LAN), and auth (loopback exemption).
- 107 unit tests (test_url_scheme.py + test_net_classify.py).
net_classify.is_blocked_for_ssrf is the primitive the device-driver
validate_device methods will use in the next commit to close HIGH #4
from the review.
Six single-pixel LED clients (Yeelight, WiZ, LIFX, Govee, Nanoleaf, BLE)
had byte-for-byte identical local copies of the strip-averaging helper.
Consolidates into core/devices/pixel_reduce.average_color so the next
single-pixel driver can drop the local copy and so behavior changes
land in one place.
Hue is intentionally left out -- its Entertainment API addresses up to
seven lights individually rather than averaging.
Behavior is byte-identical (each call site re-imports under the same
underscore-prefixed local name). 1358 tests still pass.
Adds support for Nanoleaf controllers (Light Panels / Canvas / Shapes /
Lines / Elements) via the documented HTTP REST API on port 16021.
First concrete consumer of the pair-UX scaffold from commit 2f31680 --
the abstraction is no longer speculative.
Backend:
- NanoleafClient is a single-pixel HTTP adapter: averages the strip to
one RGB triple, converts to Nanoleaf's HSB scale (H 0-360 / S 0-100 /
B 0-100), and PUTs to /api/v1/<token>/state with duration:0 so
transitions are instant for ambilight. Brightness is clamped to >=1
because Nanoleaf rejects brightness=0.
- pair_nanoleaf(host) implements the two-step handshake: POST
/api/v1/new during the 30-second pairing window the controller opens
after the user holds the power button for 5 s.
200 -> {auth_token: "..."}
403 -> raises PairingNotReady ("Hold the power button...")
other / transport error -> RuntimeError wrapping the cause
- NanoleafDeviceProvider.pair_device returns {nanoleaf_token: ...}
forwarded by POST /api/v1/devices/pair to the frontend for inclusion
in the subsequent create payload.
- mDNS discovery via _nanoleafapi._tcp (and the v1 variant); failures
yield [] rather than raising.
- Health check probes /api/v1 without a token (401/403 still proves
the host is alive).
- NanoleafConfig has nanoleaf_token + nanoleaf_min_interval_ms
(default 100 ms = ~10 Hz; HTTP overhead caps practical max ~20 Hz).
- Auth token encrypted at rest via _enc/_dec, matching Hue / BLE-Govee.
- 42 unit tests cover URL parsing, RGB->HSB conversion, pairing
handshake (200 / 403 / 500 / missing-token / transport-error),
state mutations, brightness clamp, set_power / set_brightness /
set_color, connection lifecycle, provider validate / pair /
discover / capabilities, and Device.to_config round-trip including
the encrypted-token roundtrip via to_dict + from_dict.
Frontend:
- 'nanoleaf' in DEVICE_TYPE_KEYS (next to 'govee'), HEXAGON icon
(deliberate departure from the smart-bulb lightbulb family --
Nanoleaf is panels, not bulbs, and the brand identity is hexagonal).
- isNanoleafDevice predicate + per-type field show/hide.
- Pair flow integration: when the device type is Nanoleaf, the add-
device modal retitles its submit button to "Pair Device" and
intercepts the submit. handleAddDevice awaits
runPairingFlow({deviceType: 'nanoleaf', url}), merges result.fields
({nanoleaf_token}) into the create body, then POSTs. On
PairingCancelled the user stays on the modal silently.
- Settings modal exposes the rate-limit field and a read-only
"Paired" indicator reusing the pair-modal success badge. The token
itself is never rendered to the DOM and never sent on update --
re-pairing requires delete + re-add.
- Per-type pairing instructions in en/ru/zh
(device.nanoleaf.pair.instructions) that the scaffold's i18n lookup
resolves automatically.
- Bundle: +6.4 KiB (pairing-flow.ts was tree-shaken before this
commit; now both it and the Nanoleaf branches are baked in).
The pair-UX scaffold is now proven, not speculative. Tuya and Twinkly
can follow the same shape when their phases arrive.
Lays the groundwork for device families that require a one-time
physical pairing action (Nanoleaf hold-power-button, Tuya local-key
extraction, Twinkly network-setup mode, Hue link-button). No driver
uses it yet -- Nanoleaf will be the first concrete consumer.
Phase 2 as originally written had three bullets; only this one was
genuinely missing work. The other two (generic NetworkDiscoveryService
fan-out, unified scan-network UI) were already solved at the route
level by the existing /api/v1/devices/discover handler running all
providers in parallel via asyncio.gather(return_exceptions=True).
Marked WONTDO in TODO.md with rationale.
Backend:
- LEDDeviceProvider gains an async pair_device(url) -> dict method.
Default raises NotImplementedError so missing implementations on a
requires_pairing provider fail loud at request time.
- New PairingNotReady exception, distinct from generic errors so the
route handler can return 409 (user must perform the physical action,
retry possible) instead of 500.
- POST /api/v1/devices/pair endpoint with PairDeviceRequest /
PairDeviceResponse schemas. Status-code mapping:
200 -> paired, fields returned for the subsequent create payload
400 -> unknown device type, or type doesn't support pairing
409 -> PairingNotReady (retryable from the UI)
422 -> invalid URL / device configuration (ValueError)
502 -> transport / network failure (other exceptions)
500 -> provider returned a non-dict (defensive)
- 8 route tests register a stub provider and exercise every
status-code path.
Frontend:
- New modals/pair-device.html with five state blocks (idle / pairing
/ not_ready / success / failed) toggled via data-pair-state, plus
a 30-second SVG progress ring with monospace countdown.
- New features/pairing-flow.ts exposing
runPairingFlow({deviceType, url, instructionsKey?}) ->
Promise<{fields: Record<string, unknown>>. Wires the modal to the
pair endpoint, maps response codes to UI states, AbortControllers
in-flight fetches on cancel. Exports a PairingCancelled sentinel
error class.
- Generic pairing.* i18n keys in en/ru/zh. Drivers will add their own
device.<type>.pair.instructions key that overrides the default.
Design decisions (per frontend-design skill):
- Single SVG ring + centered countdown (HomeKit-style)
- Instructions stay visible during pairing, dimmed to 60% via :has()
- Success state held 450 ms before auto-dismiss
- Cancel-X in the footer; primary action lives in the state block
- prefers-reduced-motion disables pulse/fade/ring transitions
Note: the components.css diff includes a pre-existing MiniSelect block
from the user's parallel work; pairing-specific styles are the second
hunk (lines ~1628+).
Adds support for Open Pixel Control receivers (Fadecandy boards,
xLights/Falcon endpoints, OPC bridges, art-installation controllers,
hobbyist LED driver software). OPC is a tiny TCP protocol on port
7890 with a 4-byte header [channel][cmd][len_hi][len_lo] + RGB body.
Backend:
- OPCClient opens one persistent TCP connection and streams frames as
header+body byte pairs. Channel 0 broadcasts to every output on the
OPC server; channels 1-255 address a specific channel on multi-output
servers (Fadecandy with multiple Open Pixel chains).
- supports_fast_send=True with a synchronous send_pixels_fast hot path.
The fast path skips the async drain so the OS write-buffer flushes
on its own schedule -- exactly what ambilight streaming wants.
- Brightness applies client-side before the frame is sent (OPC has no
reply channel for hardware-side brightness).
- Health check opens a TCP connection and closes it.
- OPCConfig joins the typed config union; storage gains an opc_channel
field; full to_dict/from_dict/to_config wiring.
- 36 unit tests cover URL parsing, header construction, send_pixels
emitting header+body in order, brightness application, list and
flat-array input shapes, drain behavior, connection lifecycle,
provider validate/discover/capabilities, Device.to_config round-trip.
Frontend:
- 'opc' in DEVICE_TYPE_KEYS (next to 'ddp'), paper-plane icon -- same
as DDP since both are open pixel-streaming protocols.
- isOpcDevice predicate + per-type field show/hide.
- Optional channel number input (default 0 = broadcast) with hint copy
explaining the channel semantics.
- Locale strings in en/ru/zh.
No native discovery (OPC has no discovery protocol); users supply
the receiver IP manually.
Adds support for Govee Wi-Fi smart bulbs and ambient-lighting kits via
their LAN API (opened in 2023). Discovery is multicast UDP on
239.255.255.250:4001; control commands go unicast to the device's port
4003; responses arrive on port 4002.
Each device requires "LAN Control" toggled ON in the Govee Home app
(Device -> settings -> LAN Control). Devices with LAN Control disabled
silently fail to appear in discovery and won't respond to commands; the
UI hint copy reminds users.
Backend:
- GoveeClient is a single-pixel UDP adapter: averages the strip to one
RGB triple and pushes a 'colorwc' command with colorTemInKelvin=0 to
select pure RGB mode (non-zero kelvin would switch the bulb to CCT
mode and ignore the RGB values).
- Brightness folds into the RGB scaling so we burn one packet per
frame instead of two.
- supports_fast_send=True with a synchronous send_pixels_fast hot path.
Default rate gate 50 ms (~20 Hz); UDP fire-and-forget tolerates it.
- Multicast discovery: scan request to 239.255.255.250:4001, listen on
port 4002, parse the inner data dict for IP + device-id + SKU +
firmware version. Degrades to [] when port 4002 is already bound or
network is unavailable.
- Health check sends devStatus and waits 1.5s for any reply; the error
message points at the LAN-Control toggle since that's the #1 root
cause of silent failures.
- GoveeConfig joins the typed config union; storage gains
govee_min_interval_ms; full to_dict/from_dict/to_config wiring.
- 40 unit tests cover URL parsing, scan-reply parsing (rejecting
non-scan commands and malformed JSON), payload builders (colorwc
with colorTemInKelvin=0, brightness clamping, power as 1/0 not
true/false), strip averaging, rate limiting, fast-send hot path,
provider validate/discover/health, Device.to_config round-trip.
Frontend:
- 'govee' in DEVICE_TYPE_KEYS (next to 'lifx'), lightbulb icon
(deliberate smart-bulb family grouping).
- isGoveeDevice predicate + per-type field show/hide.
- Rate-limit number input (default 50 ms).
- URL hint copy explicitly instructs users to enable LAN Control in
the Govee Home app -- the #1 source of "why isn't my Govee
responding?" support churn.
- Locale strings in en/ru/zh.
Adds support for LIFX smart bulbs and lightstrips that speak the LIFX
binary UDP protocol on port 56700, with broadcast LAN discovery via the
standard GetService/StateService probe.
Backend:
- LIFXClient is a single-pixel UDP adapter: averages the strip to one
RGB triple, converts to LIFX HSBK (16-bit hue/saturation/brightness +
kelvin), and pushes a tagged SetColor packet so all bulbs on the
subnet act on it. Brightness folds into the HSBK brightness channel.
- Hand-rolled packet builder: 36-byte LIFX header (frame +
frame-address + protocol-header) + variable-length payload. Source
ID 'LGGR' identifies LedGrab in protocol logs.
- supports_fast_send=True with a synchronous send_pixels_fast hot path
-- UDP costs nothing, so the default rate gate is 50 ms (~20 Hz) to
match LIFX's documented <=20 cmd/sec recommendation.
- Broadcast discovery sends GetService and parses StateService replies
back into IP + MAC + service-port triples. Broadcast failures yield
[] rather than raising.
- Health check sends GetService and waits 1.5s for any reply on a
one-shot UDP socket.
- LIFXConfig joins the typed config union; Device storage gains a
lifx_min_interval_ms field; full to_dict/from_dict/to_config wiring.
- 47 unit tests cover URL parsing, RGB->HSBK conversion (red/green/
blue/white/black/clamping), packet construction (size, msg type,
tagged flag, target MAC, sequence byte), SetColor and SetPower
payload layouts, StateService reply parsing (including rejection
of wrong msg types and runt payloads), strip averaging, rate
limiting, fast-send hot path, provider validate/discover/health,
and Device.to_config round-trip.
Frontend:
- 'lifx' in DEVICE_TYPE_KEYS (next to 'wiz'), lightbulb icon
(deliberate smart-bulb family grouping with Hue + Yeelight + WiZ).
- isLifxDevice predicate + per-type field show/hide in create and
settings modals.
- Rate-limit number input (default 50 ms) in both modals with hint
text referencing LIFX's documented <=20 cmd/sec ceiling.
- Locale strings in en/ru/zh.
LIFX bulbs are reachable from the existing "Scan network" button -- no
new discovery UI affordance was needed. No brightness_control capability
exposed; LIFX brightness is folded into the HSBK on the wire.
Adds support for WiZ Connected (Philips' budget-tier) smart bulbs that
accept JSON commands as UDP datagrams on port 38899 with broadcast LAN
discovery on 255.255.255.255:38899.
Backend:
- WiZClient is a single-pixel UDP adapter: averages the incoming strip
to one RGB triple and pushes it via setPilot with r/g/b params.
Brightness folds into the RGB scaling so we burn one packet per frame
instead of two.
- UDP fire-and-forget tolerates high update rates with no ack overhead,
so the default rate gate is 50 ms (~20 Hz) -- 10x faster than Yeelight.
- supports_fast_send=True with a synchronous send_pixels_fast hot path.
- Broadcast discovery sends the standard registration envelope; bulb
replies are parsed for IP+MAC and surfaced as DiscoveredDevice
entries. Broadcast failures (no network, firewall) yield [] rather
than raising.
- Health check sends getPilot and waits 1.5s for any reply on a
one-shot UDP socket.
- WiZConfig joins the typed config union; Device storage gains a
wiz_min_interval_ms field; full to_dict/from_dict/to_config wiring.
- 36 unit tests cover URL parsing, MAC extraction, strip averaging,
rate limiting, fast-send hot path, provider validate/discover/health,
and Device.to_config round-trip.
Frontend:
- 'wiz' in DEVICE_TYPE_KEYS (next to 'yeelight'), lightbulb icon
(deliberate smart-bulb family grouping with Hue + Yeelight).
- isWizDevice predicate + per-type field show/hide in create and
settings modals.
- Rate-limit number input (default 50 ms) in both modals with hint
text noting the UDP fire-and-forget characteristic.
- Locale strings in en/ru/zh.
WiZ bulbs are reachable from the existing "Scan network" button -- no
new discovery UI affordance was needed.
Adds support for Xiaomi/Yeelight smart bulbs and lightstrips that speak
the bulb-vendor's JSON-RPC protocol over TCP port 55443 with SSDP-style
LAN discovery on 239.255.255.250:1982.
Backend:
- YeelightClient is a single-pixel adapter: it averages the incoming
strip down to one RGB triple, packs it into the 24-bit color int the
bulb expects, and pushes it via set_rgb with sudden+0ms effect.
- Brightness folds into the RGB scaling on the wire so we burn one
command per frame instead of two.
- A configurable client-side rate gate (yeelight_min_interval_ms, default
500) keeps us under the bulb's ~1 cmd/sec cap. Frames that arrive
inside the gate no-op without TX. Music mode (~60 Hz via reverse-TCP)
is deferred -- the MVP caps at ~2 Hz and that's fine for a strip-to-
single-pixel averaging device.
- SSDP discovery scans 239.255.255.250:1982 with the bulb-specific
ST: wifi_bulb header; replies are parsed into DiscoveredDevice
entries. Multicast failures (no network, firewall) yield [] rather
than raising -- discovery is best-effort.
- Health check opens a TCP socket to the bulb and closes it.
- YeelightConfig joins the typed config union; Device storage gains a
yeelight_min_interval_ms field; full to_dict/from_dict/to_config wiring.
- 34 unit tests cover URL parsing, RGB packing, strip averaging, rate
limiting, SSDP response parsing, provider validate/discover/health,
and Device.to_config round-trip.
Frontend:
- 'yeelight' in DEVICE_TYPE_KEYS (next to 'hue'), lightbulb icon
(intentional family-grouping signal with Hue).
- isYeelightDevice predicate + per-type field show/hide in create and
settings modals.
- Rate-limit number input (default 500 ms) in both modals with hint
text explaining the trade-off.
- Locale strings in en/ru/zh.
- Drive-by: types.ts DeviceType union backfilled with 'ddp' and 'ble'
for type-safety consistency.
Yeelight bulbs are now reachable from the existing "Scan network"
button -- no new discovery UI affordance was needed.
Promotes the existing DDP packet layer (previously WLED-internal) to a
first-class device type so any DDP-speaking receiver (Pixelblaze,
ESPixelStick, xLights/Falcon endpoints, generic firmware) can be driven
directly without WLED in the path.
Backend:
- New DDPLEDClient wraps the DDPClient transport as a proper LEDClient
with supports_fast_send=True (synchronous UDP push on the hot loop).
- New DDPDeviceProvider — no native discovery, manual LED count,
capabilities = {manual_led_count, health_check}.
- DDPConfig joins the typed config union; Device storage gains
ddp_port / ddp_destination_id / ddp_color_order fields with safe
defaults (0/1/1 -> port 4048, destination 1=display, RGB byte order).
- URL scheme: ddp://host[:port] or bare host[:port] (default 4048).
- Health check resolves the host via async DNS; UDP has no reply
channel so reachability is best-effort by design.
- 29 new tests in test_ddp_led_client.py cover URL parsing, packet
hot path (brightness, list/numpy input shapes, fast vs async send),
provider validate/discover/capabilities, config round-trip via
Device.to_config() and to_dict/from_dict.
Frontend:
- 'ddp' in DEVICE_TYPE_KEYS (next to 'dmx'), paper-plane icon.
- isDdpDevice predicate + per-type field show/hide in the create &
settings modals.
- Color-order picker uses IconSelect (project rule bans plain select).
- Locale strings added in en/ru/zh.
Note: this commit also carries two pre-existing in-flight hunks that
were intermixed in the same files and could not be split out
non-interactively:
- api/routes/devices.py: URL-scheme inference for bare WLED hosts,
safer error messages, exception-isolated parallel discovery.
- storage/device_store.py: secret_box helpers + at-rest encryption of
Hue / BLE-Govee / MQTT credentials.
Both are independent of DDP and intentional per the user.
Extend the editor's Preview button to render unsaved form values for every
CSS source type that can be previewed without external calibration. New
types now supported transiently: audio, math_wave, weather, game_event,
api_input, mapped, composite, processed.
Backend (preview WebSocket):
- Dispatch in _create_stream by source_type, injecting the dependencies each
stream needs (audio managers, weather manager, value stream manager, CSPT
store via public get_cspt_store, color strip stream manager).
- Roll back clock + stream resources if start() fails so failed previews
don't leak refs.
- On source_type change mid-preview, drop the rebuilt-stream reference if
rebuild fails and close the WS rather than poll a stopped stream.
Stream lifecycle fixes flushed out by the new preview paths:
- MappedColorStripStream and ProcessedColorStripStream now stamp a per-
instance UUID into the sub-stream consumer_id so concurrent consumers
(multiple preview WS connections) don't collide in the CSM registry.
- ProcessedColorStripStream.update_source now re-acquires the input stream
when input_source_id changes (previously silently kept the old input).
Frontend:
- Expand _PREVIEW_TYPES; route non-quirky types through a new exported
getCSSEditorPreviewPayload helper that reuses the existing per-type
handler registry.
- For picture / picture_advanced / key_colors (which depend on calibration
or rectangles edited elsewhere), show a clearer "save the source first"
message instead of the generic "unsupported" toast.
- New Z2MLightOutputTarget storage, processor, editor and routes for
Zigbee2MQTT light entities (shares the HA-Light editor UI via the new
light-target-editor module)
- Replace global MQTTService/MQTTConfig with per-source MQTTManager +
MQTTRuntime; thread mqtt_source_id through Z2M targets, DIY MQTT
devices, and the automation engine
- Migrate legacy single-broker YAML/env config to a "Default Broker"
MQTTSource on startup (core/mqtt/legacy_migration.py) and drop the
obsolete core/mqtt/mqtt_service.py
- Refresh /api/v1/system integration status to surface every MQTT source
- Extract shared light-target editor and refactor OutputTargetStore +
output_targets routes around typed factories / auto-registry
- Modal CSS polish, locale strings, and storage/bindable test coverage
Cache the np.max(frame) result keyed on frame identity. The
min_brightness_threshold check ran a full reduction over the LED
array on every loop iteration even when the frame reference was
unchanged — at 60 fps with multiple targets this added up to
hundreds of redundant reductions per second.
- LiveStream: add frame_id counter + Condition with wait_for_new_frame()
helper. Producers (ScreenCaptureLiveStream, ProcessedLiveStream,
StaticImageLiveStream, VideoCaptureLiveStream) now signal_new_frame()
on each new frame; consumers (PictureColorStripStream, ProcessedLive
Stream) wait on the event with frame_time as a safety timeout
instead of polling + sleeping. Cuts glass-to-LED latency at matched
FPS by up to one frame_time.
- ProcessedLiveStream ring buffer: 3 -> 5 slots. The previous "max 2
frames in flight" assumption ignored the multi-consumer case where
several PictureColorStripStream/HA-target threads can hold the same
_latest_frame reference while we wrap. 5 slots gives ~83 ms of
consumer-read margin at 60 FPS.
- PictureColorStripStream advanced mode: reuse the already-fetched
primary frame instead of re-acquiring its lock from _live_streams.
- _blend_u16: use cv2.addWeighted (single SIMD-fused pass) when cv2
is available; numpy fallback unchanged. Output verified bit-equal
to the existing 6-pass implementation.
- FrameLimiter.wait: drop the 1 ms minimum-sleep floor. Over-budget
loops no longer add an extra ms per iteration; the cap on achievable
rate (~750 fps) is removed.
- WGC: replace per-frame ~30 MB BGRA->RGB fancy-index allocation with
cv2.cvtColor into a 3-slot pre-allocated RGB pool. Use gc.collect(0)
on cleanup instead of full GC to avoid multi-hundred-ms stalls.
- MSS: switch from screenshot.rgb (pure-Python BGRA->RGB rebuild) to
screenshot.raw + cv2.cvtColor into a pooled buffer. Add cheap 256-byte
hash-based change detection so idle frames return None — matches
DXcam/BetterCam semantics.
- DXcam/BetterCam: fix silent factory leak — Python name-mangling
rewrote self._dxcam.__factory to _DXcamCaptureStream__factory inside
the class body, so cleanup never reached the real attribute. Use
getattr with string literal to bypass mangling.
- calculate_dominant_color: replace np.random.choice(replace=False)
(full sort) with np.random.randint, and np.unique(axis=0) (lexsort)
with packed-RGB np.bincount. ~10x faster on dominant mode.
- calibration._map_edge_average: switch cached scratch buffers from
float64 to float32. Halves memory bandwidth on the dominant reduction
path; range-safe up to 8K screens.
- All engines: per-frame DEBUG logs use structlog kwarg style instead
of f-strings to avoid per-frame string allocation.
`_send_entity_color` was multiplying the per-mapping `brightness_scale`
into the brightness payload twice when the effective scale was below 1,
yielding a quartered output for a configured half-scale. Conversely,
when the value-stream multiplier exceeded 1.0 with a default scale,
the entire scaling step was skipped and the boost was lost.
Compute brightness as `clamp(max(r,g,b) * bs * vs, 0, 255)` once and
ship it directly, with regression tests pinning the half-scale, boost,
and 255-clamp cases.
Add 76 new icons to the custom card-icon picker and introduce five new
categories: weather, nature, controls, status, office. Existing icon ids
are unchanged so persisted card icons keep resolving.
- icon-paths.ts: +36 Lucide path constants (weather, nature, room,
office, media, hardware, lighting variants)
- device-icons.ts: extend IconCategory union and CATEGORIES; add
registry entries with labels + search aliases
- en/ru/zh locales: 5 new category labels + 76 per-icon labels each
(126 device.icon keys per locale, fully aligned)
Tabs scroll horizontally via existing overflow-x; no migration needed
(picker reads/writes ids by value, missing ids fall back to inheritance).
Adds a comfortable/compact/dense/row toggle to every card grid in the
app. Each surface (LED devices, targets, automations, scenes, sources,
streams, dashboard subsections, etc.) remembers its mode independently.
Persistence mirrors dashboard-layout: localStorage cache for first paint,
debounced PUT to /api/v1/preferences/card-modes (new endpoint) for
cross-browser sync. Surface registry is open — any non-empty key
accepted server-side; modes validated against {comfortable, compact,
dense, row}.
CSS is token-driven: grid min-width and gap come from --card-grid-min /
--card-grid-gap / --card-grid-min-narrow / --card-grid-gap-narrow /
--templates-grid-min / --templates-grid-gap defined on :root, overridden
per [data-card-mode]. Dense/row also hide .mod-leds, collapse secondary
button labels, and tighten .mod-metrics; row collapses the grid to one
full-width column. Coexists with the existing per-section [data-density]
on the dashboard tab — different attribute, additive concern.
Toggle UI auto-mounts into every CardSection header (18+ surfaces) plus
the six dashboard subsections via post-render mount; teardown tracking
keeps the listener Set bounded across re-renders.
i18n: card_mode.{tooltip,comfortable,compact,dense,row} in en/ru/zh.
Tests: 9 new cases in tests/test_preferences_card_modes_api.py covering
defaults, round-trip, validation, open-registry keys, row mode, delete.
Rename `cfg` parameter/local in resolve_mqtt_password to `config`
for PEP 8 compliance. Drop the broken reference to the long-removed
docs/plans/device-typed-configs.md from TODO.md.