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.
Reorder the lifespan shutdown so processor_manager.stop_all() runs before
ha_manager.shutdown(), mqtt_manager.shutdown(), and mqtt_service.stop().
HA-light targets check `_ha_runtime.is_connected` before applying their
`stop_action` (turn_off / restore) and silently skip when HA is already
disconnected; MQTT-output devices need the broker connection alive to
send restore frames. The previous order tore those down first, turning
"stop_targets" into a no-op for those targets — most visible when
closing via the tray Shutdown button.
Also moves automation_engine.stop(), discovery_watcher.stop(), and the
OS notification listener stop ahead of processor stop so they can no
longer fire events into a shutting-down processor manager. Independent
services (weather, update checker, auto-backup) now run last, where
their order does not matter.
Bonus: if the daemon-thread join times out (10 s) and the rest of
shutdown is cut short, the user-visible part — targets stopping — has
already run.
Extends the icon-plate work from devices and output targets to every
remaining card type — 18 new entities, 20 in total. Users can now pick
a curated icon (with optional colour override) for any card on any tab,
and the picker reuses the same modal, recent-strip, search, and
category tabs introduced for the device picker.
Foundation:
- icon-picker.ts — replace the hardcoded 2-entry adapter record with a
Map<EntityType, EntityTypeAdapter> and expose
registerIconEntityType() + makeSimpleIconAdapter() so each feature
module owns its own adapter (~6 lines per type).
- bodyExtras hook on adapters, keyed off id, lets discriminated routes
(output-targets target_type, picture-sources stream_type, audio /
value / color-strip-sources source_type) accept icon-only PUTs.
- core/card-icon.ts — new makeCardIconFields(type, id, entity) helper
spreads iconHtml / iconColor / iconAttrs into a mod-card head in one
line.
- _onDocumentClick now accepts any registered type instead of a
hardcoded device/target check.
Backend (purely additive — no migrations needed thanks to JSON-blob
storage):
- 18 dataclasses gained icon: str = "" + icon_color: str = "" with
emit-when-truthy serialisation and "" defaults on load.
- All matching Create / Update / Response Pydantic schemas gained the
fields with the standard Optional[str] + max_length=64/32 +
description set.
- All routes' response builders use
getattr(entity, "icon", "") or "" so existing rows render unchanged.
- ValueSource and CSS handle icon/icon_color on the base class so all
source-type subclasses inherit them automatically.
Frontend wiring (12 modules):
- streams.ts — picture sources, capture templates, PP templates,
CSPT, audio sources, audio templates, gradients (built-in
gradients keep no plate).
- automations, scene-presets, sync-clocks, weather-sources,
value-sources, mqtt-sources, home-assistant-sources,
game-integration, audio-processing-templates, assets,
color-strips/cards.
- pattern-templates skipped — uses the legacy wrapCard({content,
actions}) string API, separate migration.
Dashboard cards now also display the chosen icon:
- Targets already had it (with device inheritance for LED targets).
- Sync clocks, automations, and scene presets gained the same plate
via a shared _dashboardIconPlate helper that mirrors the mod-card
layout (mod-head--with-icon class flips on when present).
i18n: 20 new device.icon.entity.<type> labels in en/ru/zh.
Verification:
- ruff check src/ tests/ — clean.
- npx tsc --noEmit — clean.
- npm run build — 2.6 MB bundle.
- pytest tests/ --no-cov — 949 passed (no regressions).
Pending: manual smoke test on each card type — open picker, save, and
confirm the channel-color preview matches the live card.
HALightOutputTarget gains a `source_kind` field with two modes:
- `css` (existing): per-mapping LED segments averaged from a ColorStripSource.
- `color_vs` (new): one colour from a colour-returning ValueSource pushed to
every mapped entity (mapping LED ranges are ignored in this mode).
Backend wiring:
- Schema/route: add `source_kind` + `color_value_source_id` to create/update/
response payloads, with VS existence + return_type=color validation.
- Storage: persist new fields, with defensive `or ""` coalesce so legacy rows
written via resolve_ref with None survive the str-typed response schema.
- Processor: ha_light_target_processor reworked to drive both source kinds
(incl. update_target_settings hot-swap of source mode). New unit tests in
tests/core/test_ha_light_target_processor.py and extended store tests.
Frontend:
- ha-light editor modal: collapsed Color Strip + Color VS into one
"Color Source" picker with grouped headers; mappings list shows a
mode-aware hint when broadcasting a single colour.
- EntityPalette: support non-selectable header rows (with keyboard / filter
handling) for grouped source pickers.
Bundled UI polish (icon inheritance + cleanup):
- Custom card icons now flow into more surfaces: command palette, dashboard
target cards, scene-preset target picker, calibration test-device picker,
and the LED-target device picker. LED targets inherit their device's icon
when none is set on the target itself.
- Empty mod-card icon plates render as a dashed "+" placeholder when an
icon-picker hook is wired, so the action stays discoverable.
- Icon picker: distinct "HA light target" eyebrow label and supports
HA-light cards (data-ha-target-id) for channel-colour resolution.
- Update banner: "View release" now opens the in-app Update settings tab
instead of an external link; uses the sparkles icon.
- Color-strip delete: cleaner toast on 409 conflict.
Extends the icon-plate work from the device cards to LED and HA-light
output targets, and adds finalization behaviour for HA-light targets.
Targets:
- Add `icon` and `icon_color` fields to OutputTarget (LED + HA-light).
- LED target cards inherit the icon from their referenced device when
no override is set; the icon picker shows an "inherited" indicator.
- Promote the device link from the meta line to a chip with the
device's custom icon, leaving the head row free for the icon plate.
HA-light:
- New `stop_action` field with three modes: `none` / `turn_off` /
`restore`. Processor snapshots mapped-entity states at start and
applies the chosen action on stop (rgb / hs / color_temp / brightness
restored where present).
- Editor modal exposes the choice via an IconSelect of three modes.
Adjacent fixes:
- Fader slider hit-zone now overlays the visible track exactly,
regardless of label/value column widths.
- Dashboard customise drag-drop indicator splits into before/after
rather than highlighting the whole row.
- Picture-source EntitySelect resyncs its visible value on load.
A user-chosen icon ("mouse", "motherboard", "keyboard"…) renders as a
44x44 instrument-panel face plate at the leading edge of .mod-head on
device cards. Optional per-card; null hides the plate and reverts to
the existing badge-only head.
- Storage/schema: new icon, icon_color fields on Device + DeviceUpdate /
DeviceResponse. SQLite stores entities as a JSON blob, so no migration
is needed; from_dict defaults handle existing rows.
- Curated 47-icon library across six categories (Hardware / Lighting /
Rooms / Media / Signal / Ambience), reusing the existing Lucide path
module; adds circuit-board, bed, armchair, leaf paths.
- mod-card.ts: ModHeadOpts gains iconHtml / iconColor / iconAttrs;
ModMenuItemOpts gains optional dataAttrs. The plate is rendered when
iconHtml is supplied; otherwise no layout change.
- Picker modal (icon-picker.html + features/icon-picker.ts): live
preview, search, six category tabs, recent strip, channel-color
override toggle. Wired through document-level click delegation on
[data-icon-picker-trigger="<deviceId>"] — no window globals, no
inline onclick string. Sets the precedent for migrating other card
actions off window in a follow-up.
- en/ru/zh locales for picker UI + categories.
Includes a docs/ mockup that's the source-of-truth for the design.
Move the keystore guard from after the Decode step (step 9) to right
after Resolve build label (step 3). A release tag pushed without
ANDROID_KEYSTORE_BASE64 configured now fails in seconds instead of
after JDK + Python + Android SDK + NDK install (~3-5 min of wasted
runner time). Switched the condition from steps.keystore.outputs.present
to env.ANDROID_KEYSTORE_BASE64 since the env var is set at job level
and the keystore decode step has not yet run at the new position.
- Move the device refresh button into the label row next to "Audio Device:"
so it can no longer overflow the Source panel edge; introduces a small
.label-row-action style alongside .hint-toggle.
- Restore device selection after refresh by matching on (index, loopback)
value first, with a trimmed name fallback for OS-side reindexing.
- _selectAudioDevice now syncs the EntitySelect trigger so the visible
label matches the underlying <select> when the modal opens in edit mode.
- Drop unused min-width/overflow on .transport-status.
The "Created by Alexei Dolgolyov..." line lived in a global app
footer that took up vertical space on every page. Move the author
+ contact details into the About tab of the global settings modal
(rendered by renderAboutPanel), where they sit next to the version
pill and license. Adds a localized "donation.about_author" key
(en/ru/zh) and matching .about-hero .about-author styles. Removes
the now-unused .app-footer / .footer-content rules.
Performance (LED hot path, allocation-free per-frame):
- Adalight: dedicated single-worker tx executor (avoids asyncio.to_thread
overhead), pre-allocated wire buffer + uint8 scratch, header struct
precomputed. New tests cover header format, buffer reuse, non-contiguous
input, and brightness scaling.
- DDP: pre-built struct.Struct for the 10-byte header, allocation-free
send buffer + memoryview emit path. New tests cover RGB/RGBW packets,
sequence/PUSH semantics, and multi-packet fragmentation.
- Calibration: precomputed Phase 3 skip-LED resampling (floor/ceil indices,
fractional weights, take/blend scratch buffers) — per-frame work is now
np.take + in-place blend, no allocations.
- WLED target processor: matching hot-path tightening.
Tutorials:
- Sub-tab switching, breadcrumb header, and prepare/switchSubTab hooks
so a tour can open/close the dashboard customize panel and resolve
targets behind sub-tabs.
- New steps for integrations tab, dashboard customize panel (presets,
global, sections, perf cells), targets, scenes, sync-clocks.
- en/ru/zh locales updated with the new tour strings.
Dashboard layout:
- Structural deep-equal so the "modified" indicator reflects truth after
a user edits then reverts, instead of a stale flag.
UI polish:
- Mod-card / modal markup pass across ~33 modal templates and the
tutorial overlay partial.
- appearance.css, modal.css, tutorials.css refresh.
Tooling:
- Add .mcp.json with code-review-graph MCP server config so the graph
tools are available to the team out of the box.
Release Notes overlay redesign (scoped via .release-notes-shell)
- Backend exposes release.assets (name/size/download_url) through
UpdateReleaseInfo so the frontend can render real download links.
- New masthead: eyebrow + display-font title + tag/published/pre-release
chip strip + close/external action buttons; opts out of layout.css's
global `header { height: 60px }` and `header::before` accent bar that
were leaking into the overlay's <header>.
- Markdown body: <code> filenames are wrapped in clickable <a> via fuzzy
asset match (exact basename, then same-extension token-overlap), with
per-asset description tooltip and a small download glyph.
- Per-asset description derived from filename pattern (Windows installer
/portable/msi, Linux tarball/AppImage/deb/rpm, macOS dmg/pkg, Android
apk/aab, iOS ipa, generic archives) with i18n keys in en/ru/zh.
- Hide checksum / signature side-files (.sha256/.sha512/.sig/.asc/...).
Settings modal & dashboard polish
- ds-section refresh, rail-channel routing, notif matrix updates.
- Dashboard customize panel + per-account layout updates.
- New docs/settings-modal-redesign.html design reference.
Streams / targets / color-strip
- Stream cards rewrite (cards.css, streams.css, streams.ts).
- Composite stream + metrics history adjustments.
- WLED target processor + color-strip pipeline refinements.
- Color-strip WS source streamer touch-ups.
Misc
- Perf charts overhaul; tabular game-integration / HA / MQTT / weather
source cards; donation/sync-clocks/scene-presets minor polish.
- New i18n keys across en/ru/zh.
Test infrastructure
- conftest pre-creates the test DB so main.py's legacy-data migration
doesn't shovel the user's production DB into the test temp dir.
- test_preferences_notifications wipes its own setting at the start of
the defaults test (was relying on isolation it never enforced).
Pre-commit gates: ruff clean, tsc clean, npm run build clean,
pytest 899/899 passing.
Drops the legacy "Pipeline details" collapsible block on running
LED target cards. Instead:
- Always-visible 4px segmented timing bar (extract / map / smooth /
send for video, read / fft / render / send for audio) — same
stage colors as before, scaled by per-segment ms cost
- One chip row beneath it: total ms / frames count / keepalive
count, using a new .chip--inline variant (display-weighted number
+ tiny mono-caps unit)
- _patchTargetMetrics now writes only the bar's segments and the
data-tm spans — bar wrapper survives across polls so the
flex-transition animates smoothly between samples
- _buildLedTimingHTML replaced by _buildLedTimingSegments (no more
header / total / legend wrappers — those live in the chip row)
Cleanup
- Drop .target-metrics-collapse / -toggle / -animate / -expanded
CSS — no callers remain
- Drop targets.metrics.pipeline from en/ru/zh locales — toggle
label is gone
mod-card.ts
- ModMetricOpts.extra: raw HTML appended after the .v cell — used
to embed a sparkline canvas inside the FPS metric block
- ModMetricOpts.valueDataAttrs: data-attrs on the .v element so
live-update patchers can target the value directly
LED target card
- FPS sparkline (mod-metric-spark-canvas) is embedded INSIDE the
FPS cell as a sibling of .v — was a separate target-fps-row
block before, which floated under the metrics grid
- Label hardcoded to "FPS" (the i18n value "Target FPS:" was
meant for the editor field, not the readout)
- Uptime cell gets ICON_CLOCK; Errors cell gets ICON_OK / ICON_WARNING
based on count — matches dashboard cell decorations
- Drops the leading FPS icon (display-font number is the focal
element; no icon needed)
- _patchTargetMetrics now emits the dashboard FPS shape:
current<span.dashboard-fps-target>/target</span>
<span.dashboard-fps-avg>avg N.N</span> — picks up dashboard.css
styling for free
HA Light target card
- Same icon treatment (Uptime → clock; HA → ok/warning by
ha_connected); FPS icon dropped
Grid sizing
- .devices-grid bumped from minmax(300px, 1fr) / gap 20px to
minmax(min(380px, 100%), 1fr) / gap 14px — matches the
dashboard's section grid so metric values like "1m 43s" stop
truncating at the typical desktop width
When the user enables "Start with Windows" in the installer, the app
launches on every PC login. Previously each login popped a fresh WebUI
tab, which is noisy for a tray-resident background service.
The autostart shortcut now passes --autostart to start-hidden.vbs, which
sets LEDGRAB_AUTOSTART=1 in the child env. __main__ checks this flag
alongside LEDGRAB_RESTART when deciding whether to open the browser.
Manual launches (desktop/start-menu shortcuts) and the installer's
post-install "Launch LedGrab" finish-page action are unchanged — they
don't pass the arg, so they still open the WebUI tab.
start defaults to 0, length defaults to led_count - start (the rest of
the strip from start). A single segment with only mode + color now
fills the entire strip — no more length: 9999 magic value clients have
to pass.
Buffer auto-grow only fires for segments with an explicit length past
the current end; implicit "to the end" segments adapt to the current
strip size.
Dashboard cards (mod-card system)
- New mod-card / mod-menu modules backing dashboard cards
- Reworked card colors, sections, dashboard layout, perf charts
- Channel-stripe styling, hairline borders, signal-flow animation
on running cards, refined metric grid
Multiselect bulk toolbar
- Replaced tri-state checkbox with explicit Select-all / Deselect-all
icon buttons; both disable when not applicable
- Dim + slight blur on non-selected siblings during selection mode so
the active picks pop; selected card gains a subtle lift + primary-color
glow halo
- Bulk tick uses ICON_CHECK from the icon registry (was U+2713) and
scale-pops in via a cubic-bezier overshoot keyframe
- Toolbar restyled with luxury gradient bg, top accent stripe, glass
blur, neon hover glows on each button group
Settings modal
- Tab bar converted to icon-only (cog / hard-drive / bell / palette /
refresh / help) so labels never overflow at any locale; title and
aria-label preserve translated names. Tabs distribute evenly via
flex: 1 1 0 + space-around — no overflow possible
- IconSelect auto-populates <option> elements when the underlying select
is empty, fixing the blank notification triggers (root cause: setting
.value on an empty select is a no-op)
- Tab activation calls scrollIntoView on the active button as a safety
net for narrow viewports
Modal exit animation
- Added symmetric fadeOut + slideDown keyframes; .modal.closing applies
them with animation-fill-mode: forwards
- Modal.forceClose() defers display:none until animationend (with timer
fallback). State cleanup (focus, body lock, stack) runs immediately so
callers querying state get correct values
- isOpen returns false during the close animation; open() cancels any
in-flight close so re-open works during the animation
- prefers-reduced-motion disables all modal animations
Locale picker
- Dropped redundant English/Русский/中文 long-form labels — picker now
shows only EN / RU / ZH
- IconSelect trigger/cell hides empty icon/label slots via :empty so the
layout collapses cleanly for minimal items
Filter input (cards section)
- Embedded magnifier icon via data URI (no HTML change); monospace
uppercase placeholder, lux-bg-0 background, neon focus ring with inset
shadow + outer glow
- Reset button only shows when the input has content (CSS-only via
:placeholder-shown sibling selector — JS-resilient)
Snack toast
- Glass background (gradient + backdrop-blur) with top channel-color
accent stripe matching the modal/toolbar language
- Per-type --toast-ch drives border/glow/timer color (success → primary,
error → danger, info → info)
- Undo button gets a tinted hover with channel-color halo
Top header toolbar
- Removed hairline border from .header-btn for a flatter look; hover
keeps the subtle background tint and primary-color glow
Device URL hyperlink
- Styled .mod-meta__link to pick up the card's --ch accent (instead of
inheriting browser-blue underline). Dotted underline at rest solidifies
on hover; soft text-shadow glow; web icon dims at rest, brightens on
hover
Misc
- ICON_CHECK and ICON_HARD_DRIVE added to the icon registry
- Existing card-redesign demos checked in under docs/
- Removed obsolete docs/plans/device-typed-configs.md
Configurable device-event notifications: snackbar + Web Notifications
for online/offline (configured targets) and discovery (new WLED/serial
on the LAN/USB) events, with per-event channel matrix and background
discovery toggle.
Surface device connection state changes (configured target online/offline)
and discovery events (new WLED on LAN, new serial port, devices that
disappear) through a configurable per-event channel matrix:
none / snack / OS / both.
- Backend: long-running mDNS browser + 10 s serial poller in
core/devices/discovery_watcher.py, gated by user pref. Reuses the
existing device_health_changed event for online/offline transitions.
New GET/PUT /api/v1/preferences/notifications endpoint with Pydantic v2
schema (channel matrix + background-discovery flag + grace/debounce).
13 new tests, full suite still 899 passing.
- Frontend: features/notifications-watcher.ts with startup-grace +
flap-debounce + bulk-coalesce pipeline. Web Notifications API for the
OS channel (no platform-specific code, works in PWA shell).
New "Notifications" tab in Settings with 4 IconSelect rows + bg toggle
+ permission row + test button. en/ru/zh translations.
Defaults: device_offline=both (urgent), online/discovered=snack, lost=none,
background discovery on. Already-configured devices are filtered from
discovery events to avoid double-notifications.
Chrome deprecated apple-mobile-web-app-capable in favor of the
standard mobile-web-app-capable. Add the new tag while keeping the
Apple variant for iOS Safari compatibility.
Release-bump commits don't change code that affects lint/tests, and
release.yml already runs in parallel. Manual dispatch lets us re-run
on demand if needed.
- ``tests/test_preferences_api.py`` no longer captures the auth API
key at module-import time. The new ``client`` fixture resolves it
inside its body and bakes the Bearer header into ``TestClient.headers``,
so the e2e conftest swapping the global config singleton during
collection cannot leave the test holding a stale 401-bound header.
Same proven pattern as ``test_audio_processing_templates_api.py``.
- ``.gitignore`` now anchors ``/server/src/data/`` defensively. If the
server is launched from ``server/src/`` (uncommon but possible during
ad-hoc debugging), its relative ``data/`` resolves there. Templates
now live in SQLite (``capture_templates`` / ``pattern_templates`` /
``postprocessing_templates`` tables); any stale ``*.json`` that
lands in that directory is a runtime export and must not be
committed.
- Three such stale exports were untracked at the start of the
pre-merge audit and have been deleted from the working tree.
- ``TODO.md`` flips the shutdown-action checklist to done and notes
that real-hardware verification (WLED + serial after Ctrl+C) is
still pending.