Lets users wire the system end-to-end from the graph, and fixes the core bug that made drag-to-wire silently fail. - Fix drag-to-wire 422s across 5 entity kinds: updateConnection() now echoes the target's discriminator (source_type/stream_type/target_type) into the partial PUT, so value/colour-strip/audio/picture sources and output targets all wire correctly. New contract test (54 cases) in test_graph_wiring_contract.py. - Re-wire composite layers / mapped zones from the graph (right-click a layer/zone source edge -> Re-wire). Whole-list write preserves every sibling layer/zone setting, with an optimistic-concurrency guard and undo. - Secret-safe /graph topology: project entities to id/name/subtype + reference roots so the endpoint cannot leak webhook tokens or other credentials. - Carry slot indices on list edges; node custom-icon + schema-drift refinements; rewire i18n keys (en/ru/zh); wiring-control roadmap (TODO.md).
69 KiB
LedGrab TODO
HTTP polling automation trigger
Goal: a new automation trigger that periodically polls an HTTP endpoint and activates a scene when the response matches a condition. Split into three single-responsibility entities so the endpoint can be reused beyond automations (e.g. as a value-source driving brightness/color):
HTTPEndpoint(storage/http_endpoint.py) — connection definition: URL + auth + headers + timeout. NO polling cadence; NO extraction.HTTPValueSource(storage/value_source.py, source_type='http') — references an endpoint + owns json_path + interval + min/max + EMA smoothing. Backed byHTTPValueStream(core/processing/value_stream.py) which lives under the existingValueStreamManager(ref-counted, one poll task per unique value source).HTTPPollRule(storage/automation.py) — thin:{value_source_id, operator, value}. Readsstream.get_raw_value()from the value source and compares with_apply_operator.
Pivoted from a 2-entity shape mid-build (was: HTTPSource+rule with interval+json_path mushed). The 3-entity shape mirrors HA's pattern (HomeAssistantSource → HAEntityValueSource → rule).
Phase 1 — endpoint + value source + thin rule (backend) ✅
storage/http_endpoint.py—HTTPEndpointdataclass with secret_box auth_token encryption +__post_init__plaintext invariant. NOdefault_interval_s(moved to value source).storage/http_endpoint_store.py—HTTPEndpointStorewith_migrate_plaintext_tokens(). ID prefixhtep_.storage/database.py—"http_endpoints"in_ENTITY_TABLES(replaces the old"http_sources").storage/value_source.py— addedHTTPValueSourcealongsideHAEntityValueSource(endpoint_id, json_path, interval_s, min/max, smoothing). Registered in_VALUE_SOURCE_MAP.storage/value_source_store.py— CRUD branch forsource_type = "http"+ new kwargs on create/update.core/processing/value_stream.py—HTTPValueStreamwith poll task +get_value()(normalized 0-1) +get_raw_value()(raw extracted value). Dispatched inValueStreamManager._create_stream. Manager now takeshttp_endpoint_storeso the stream can resolve endpoints at fetch time.
Phase 2 — rule + engine wiring ✅
storage/automation.py—HTTPPollRuleis now thin: just{value_source_id, operator, value}(no http_source_id, no json_path on the rule). Legacy keys silently dropped on load.core/automations/automation_engine.py— drops the standalone http_poll_manager; takesvalue_stream_manager. Engine_sync_value_stream_refsacquires/releases value streams for every enabled HTTPPollRule, mirroring the HA/MQTT sync pattern._evaluate_http_pollreadsstream.get_raw_value()and applies the operator._apply_operatorkept at module top.api/schemas/automations.py— RuleSchema fields are nowvalue_source_id + operator + value(dropped http_source_id + json_path).api/routes/automations.py—http_pollfactory updated.
Phase 3 — CRUD endpoints + wiring ✅
api/schemas/http_endpoints.py— Create/Update/Response/List/Test (no interval field; that's on the value source).api/routes/http_endpoints.py— full CRUD +/test+ plaintext-http-token warning.api/schemas/value_sources.py—HTTPValueSource{Create,Update,Response}added to the discriminated unions.api/routes/value_sources.py—_RESPONSE_MAPentry forHTTPValueSource.api/__init__.py—http_endpoints_routerregistered.api/dependencies.py—get_http_endpoint_store(dropped the http_poll_manager getter).main.py— instantiateHTTPEndpointStore, pass it throughProcessorDependencies, wirevalue_stream_manager+value_source_storeintoAutomationEngine.core/processing/processor_manager.py—ProcessorDependenciesgainshttp_endpoint_store; threaded intoValueStreamManager.
Phase 4 — tests ✅
tests/storage/test_http_endpoint_store.py— 14 tests (CRUD + auth_token encryption + headers + case-insensitive Authorization).tests/core/test_automation_engine.py—TestApplyOperator+TestHTTPPollRuleEvaluation(new shape: mock ValueStreamManager with_streamsdict) +TestSyncValueStreamRefs(acquire / release / disabled-ignored) +TestHTTPValueStreamExtraction(_extract_simple_pathnow lives in value_stream.py).tests/api/routes/test_http_endpoints_routes.py— CRUD shape, no auth_token leak in responses, schema-layer method allowlist, CRLF / invalid header rejection,/testendpoint, LAN policy.- Removed:
tests/core/test_http_poll_manager.py(manager deleted — polling now lives insideHTTPValueStream). - Full suite: 1426 passed, ruff clean.
Phase 5 — frontend ✅
static/js/features/http-endpoints.ts(new, ~540 LOC) — endpoint CRUD, modal subclass with dirty-check, headers row editor, test result rendering, card builder, event delegation. Mirrorshome-assistant-sources.ts.templates/modals/http-endpoint-editor.html(new) — sectioned rack-panel modal (Identity / Request / Headers / Notes) with IconSelect method picker, password-toggle on auth token, inline Test button + result block.static/js/features/value-sources.ts— addedhttpbranch with EntitySelect overhttpEndpointsCache, edit-data/defaults,onValueSourceTypeChangesection toggle, save-payload assembly + required-field validation.templates/modals/value-source-editor.html— new#value-source-http-sectionwith endpoint picker + json_path + interval + min/max + smoothing.static/js/features/automations.ts—http_pollrule type with operator IconSelect + value-source EntitySelect; hides Value field when operator isexists.static/js/features/integrations.ts—csHTTPEndpointssection, tree/tab entry, render + reconcile + delegation paths.static/js/types.ts—HTTPEndpoint,HTTPMethod,HTTPEndpointListResponse,HTTPTestRequest/Response,HTTPValueSource,HTTPPollOperator; extendedRuleType+AutomationRule.static/js/core/state.ts—httpEndpointsCache(/http/endpoints).static/js/core/icons.ts—http: P.globein_valueSourceTypeIcons.templates/index.html— includesmodals/http-endpoint-editor.html.- Locales: 77 new keys per file in
en.json/ru.json/zh.json(parity confirmed). - Verification:
npx tsc --noEmitclean;npm run buildclean (app.bundle.css 366.6kb, app.bundle.js 2.7mb).
Follow-ups (out of scope for initial PR)
- Global concurrency cap / minimum interval. Each
HTTPValueStreamruns its own task atinterval_s(min 1s); no project-wide cap. Reviewer flagged: pick a min (e.g. 5s) + max active runtimes (e.g. 32) + sharedhttpx.AsyncClientwithlimits=httpx.Limits(max_connections=N). - DNS-rebinding hardening.
safe_request_boundedvalidates the URL hostname's resolved IPs once; httpx independently re-resolves. The window is short but not zero. True fix: pin to the validated IP + set Host header (and SNI for HTTPS). This affects every outbound caller (safe_fetch, weather, image sources) — handle as a project-wide hardening, not local to this feature. delete_http_endpointorphan refs. When an admin deletes an endpoint referenced by N value sources, the value-stream task keeps polling until its source is also deleted. Same shape as the MQTT defect — fix both together (refuse-with-409 when in use, or cascade value-source deletion).- Per-endpoint
connected/ last-poll status on the response (frontend agent flagged).HTTPEndpointResponsehas no live status, unlike HA/MQTT sources. Card LEDs default to "on". Could aggregatelast_status_code/last_errorfrom allHTTPValueStreaminstances referencing the endpoint and surface onGET /http/endpoints/{id}. - Per-endpoint live
/testafter save — addedPOST /http/endpoints/{id}/test(runs stored config server-side so the auth token never round-trips) and wired a flask-icon test action on the endpoint card (toasts the result). Custom-headers section and inline test-result UI in the editor modal also restyled to match the.group-child-rowand result-card vocabulary. - Dedicated icon for HTTP value source / endpoint (frontend
agent flagged). Both use
P.globe— visually fine in practice but adding acable/webhookglyph inicon-paths.tswould improve differentiation.
Multi-broker MQTT refactor
Goal: drop the global MQTTService / MQTTConfig. Every MQTT consumer
references an MQTTSource.id; MQTTManager is the only entry point.
MQTTManager + MQTTRuntime already exist — the job is to migrate every
caller off the legacy path, then delete it.
Phase 1 — mqtt_source_id on Z2M target
- Field on
Z2MLightOutputTargetstorage dataclass (+ to/from_dict) - Field on Z2M create/update/response schemas
- Validate referenced
MQTTSourceexists at create/update - Thread through
output_target_store.create_z2m_light_target+ update - Thread through
ProcessorManager.add_z2m_light_target - Thread through
Z2MLightTargetProcessorconstructor
Phase 2 — Z2M processor uses MQTTManager
- Replace
_mqtt_servicewith_mqtt_runtimeacquired from manager start()acquire /stop()release_publish_payload→self._mqtt_runtime.publish(...)turn_off_lightsborrow-pattern via manager (mirror HA-light)- Add
mqtt_managertoProcessorDependencies/TargetContext
Phase 3 — Z2M editor UI
- Add MQTT broker
EntitySelectin Routing - Reuse
mqttSourcesCache - Wire
mqtt_source_idinto edit-load + save payload + validation
Phase 4 — DIY MQTT device (MQTTLEDClient)
mqtt_source_idfield onDevicestorage- Field on
device_config.MQTTConfig MQTTLEDClientacquires runtime inconnect(), releases inclose()- Provider threads
mqtt_managerviaProviderDeps - Device editor: MQTT source picker shown for
device_type=mqtt. Turned out the API layer was also missing it (the TODO's "backend accepts the field" was wrong —mqtt_source_idlived indevice_store+device_config.MQTTConfigbut was dropped byDeviceCreate/Update/Responseand the routes). Added: schema fields + route threading + referenced-source validation (_validate_mqtt_source_exists, mirrors output_targets) +except HTTPException: raiseguard inupdate_device(it was masking its own 4xx as 500). Frontend: brokerEntitySelect(reusingmqttSourcesCache) in both the add-device (device-discovery.ts) and settings (devices.ts) modals — shown fordevice_type=mqtt, wired into load/save/validate/dirty-check/clone. Empty = "first available broker". 4 regression tests intest_devices_routes.py::TestMqttSourceId; full suite 1567 passing; en/ru/zh keys added.
Phase 5 — AutomationEngine
- Drop
mqtt_servicector parameter - Drop legacy fallback in
_evaluate_mqtt(rule must reference a source)
Phase 6 — api/routes/system.py
- Replace integration status with
mqtt_manager.get_all_sources_status() - Update frontend dashboard payload (MQTT widget now expects a list of
sources instead of a single
enabled/connectedpair — surface in UI). Done:dashboard.ts_renderMQTTIntegrationCardrenders one card permqttStatus.connectionsentry;_updateIntegrationsInPlaceiterates the list.
Phase 7 — Startup migration
- Seed a "Default Broker"
MQTTSourceif legacy YAML / env had a broker configured and the store is empty (core.mqtt.legacy_migration) - Deprecation warning logged on migration; YAML/env no longer read after
Phase 8 — Remove legacy
- Delete
core/mqtt/mqtt_service.py - Delete
set_mqtt_service/get_mqtt_service(mqtt_client.py) - Remove
MQTTServicefrommain.py - Remove
MQTTConfig+resolve_mqtt_passwordfromconfig.py - Remove
mqtt: MQTTConfigfromConfig(withextra="ignore"so legacy YAML still loads)
Phase 9 — Verification
pytest tests/ --no-cov -qclean (973 passing; removed obsoletetest_default_mqtt_disabled)ruff check src/cleantsc --noEmit+npm run build- Smoke test: Z2M target on a configured MQTT Source publishes to broker (manual)
Refactor: typed output-target factories + auto-registry
Replaced target_type string elif chains in OutputTargetStore and
OutputTarget.from_dict with: (1) __init_subclass__ registry for
deserialization, (2) per-type typed create_*_target /
update_*_target methods called directly from the route layer's
match data: dispatch. API contract unchanged, no DB migration.
Phase 1 — Registry on OutputTarget
- Added
_registry+_type_keyClassVars +__init_subclass__(*, type_key) - Rewrote
OutputTarget.from_dictto dispatch via registry - Declared
type_key="led"/"ha_light"/"z2m_light"on the three subclasses
Phase 2 — Typed create_*_target methods
- Extracted
_resolve_brightness,_resolve_transition,_check_unique_name,_new_id_and_now,_finalizehelpers on the store - Added
create_wled_target/create_ha_light_target/create_z2m_light_targetwith per-type defaults (transition 0.5/0.3, update_rate 2.0/5.0) baked into their signatures
Phase 3 — Typed update_*_target methods
- Added
update_wled_target/update_ha_light_target/update_z2m_light_targetwith_begin_update/_commit_updatehelpers - Each typed update method validates the target's class before mutating
Phase 4 — Route migration
create_targetroute usesmatch data:to call typed store methods — no moregetattr(data, "x", default)pyramidupdate_targetroute usesmatch data:and computessettings_changed/css_changed/brightness_changedper-arm from typed fields- Helpers
_build_ha_mappings,_build_z2m_mappings,_validate_device_exists,_resolve_effective_color_vs_idextracted
Phase 5 — Decision: keep both shims
After grepping for callers, src/ledgrab/core/scenes/scene_activator.py:90
calls target_store.update_target(target_id, **changed) with a dynamically
built dict — it legitimately doesn't know the target's type at the call site.
The shims are now ~30-line dispatchers that route to typed methods (no more
inline construction elif chains), so the original anti-pattern is gone while
the generic API remains available for "don't-know-the-type" callers like the
scene activator. Tests continue to use the shorthand create_target("A", "led")
form without churn.
Phase 6 — Verify
ruff checkclean on all modified filespy -3.13 -m pytest tests/ --no-cov -q— 974 passed (was 974 before)- Manual smoke test in UI: create/edit/delete each of the three target types
Custom card icons — extend to all card types
Migrate the existing icon-plate work (devices, LED targets, HA-light targets)
to all remaining card types. ~17 entity types. Branch: feat/icons-everywhere.
Foundation
- Refactor
icon-picker.ts— replace hardcoded 2-entry_adaptersrecord with aMap<EntityType, EntityTypeAdapter>and exposeregisterIconEntityType()for feature modules to register their own. AddedmakeSimpleIconAdapter()helper that reduces a registration to ~6 lines. - Generalised
bodyExtrasfor discriminated routes (output-targetstarget_typeetc.) — now keyed off id, adapter does its own lookup. _onDocumentClickaccepts any registered type instead of hardcoded device/target check.- Locale entity-type labels added to en/ru/zh for 18 new types (picture_source, audio_source, weather_source, value_source, mqtt_source, ha_source, automation, scene_preset, sync_clock, game_integration, audio_processing_template, pattern_template, capture_template, pp_template, cspt, audio_template, gradient, color_strip_source, asset).
Backend (storage + schemas + routes per entity)
Recipe: add icon: str = "" + icon_color: str = "" to dataclass,
emit-when-truthy in to_dict, default "" in from_dict; add 3
Optional[str] Field defs to Create/Response/Update schemas; thread
getattr(entity, "icon", "") or "" into the response builder.
SQLite JSON-blob storage means no migration required.
- Integrations (6): weather_sources, value_sources, mqtt_source, home_assistant_source, sync_clocks, game_integration
- Streams (10): picture_source, audio_source, audio_template,
audio_processing_template, pattern_template, postprocessing_template,
color_strip_processing_template, color_strip_source, gradient,
capture_template (
storage/template.py— was missed by initial pass) - Other (3): automation, scene_preset, asset
Frontend (per feature module)
For each card render call:
- Use the new
core/card-icon.tshelper:...makeCardIconFields('<type>', entity.id, entity)spread into the mod-card head — computesiconHtml/iconColor/iconAttrsin one go. - Register the entity type in the feature module via
registerIconEntityType('<type>', makeSimpleIconAdapter({ … })).
Modules wired:
- streams.ts (7 cards: picture, capture, pp, cspt, audio source, audio template, gradient — built-in gradients skip the plate)
- automations.ts
- scene-presets.ts
- sync-clocks.ts
- weather-sources.ts
- value-sources.ts (bodyExtras propagates
source_type) - mqtt-sources.ts
- home-assistant-sources.ts
- game-integration.ts
- audio-processing-templates.ts
- assets.ts
- color-strips/cards.ts (bodyExtras propagates
source_type) - [WONTDO] pattern-templates.ts — uses legacy
wrapCard({content, actions})string API, not the mod-card system. Migration would be a separate effort and the cards are tiny (name + rect count) so the value is low.
Discriminated routes
Adapters provide bodyExtras to inject the discriminator field on PUT
so the Pydantic discriminated-union route validators don't reject the
icon-only update:
- output-targets →
target_type(already wired before) - color-strip-sources →
source_type - audio-sources →
source_type - value-sources →
source_type - picture-sources →
stream_type
Verification
cd server && ruff check src/ tests/cleancd server && npx tsc --noEmitcleancd server && npm run buildproduces 2.6 MB bundlecd server && py -3.13 -m pytest tests/ --no-cov -q— 949 passed- Manual: open picker on each card type, confirm save persists, confirm channel-color preview matches the live card
Device Event Notifications
Notify the user when LED devices come online/go offline (configured targets), and when new
WLED/serial devices are discovered or disappear from the LAN/USB. Each event class has a
configurable channel: none | snack | os | both. OS channel uses Web Notifications
(works in any browser tab and in the PWA shell — no platform-specific Python).
Branch: feat/device-event-notifications. Default ON.
Backend
core/devices/discovery_watcher.py— long-running mDNS browser (AsyncServiceBrowserkept alive for the process lifetime) + 10 s serial-port poller. Firesdevice_discovered/device_lostviaprocessor_manager.fire_event, suppresses events for URLs already indevice_store. Seeded ports do NOT generate startup-time toasts.- Wired into
lifespan(main.py). Gated bynotification_preferences. background_discovery_enabled. Default True. Stops before health monitor stop. api/schemas/preferences.py—NotificationPreferencesPydantic v2 model with the 4-event channel matrix,background_discovery_enabled,startup_grace_sec(0..300),flap_debounce_sec(0..60).api/routes/preferences.py—GET/PUT /api/v1/preferences/notifications, persisted underdb.set_setting("notification_preferences", …). Corrupt stored values fall back to defaults instead of 500.- Reuses existing
device_health_changedevent fromdevice_health.py(already fires online/offline transitions on the same event bus). - Tests: 7 in
tests/test_preferences_notifications_api.py, 6 intests/test_discovery_watcher.py. Full pytest suite still 899 passing.
Frontend
js/features/notifications-watcher.ts— listens to the threeserver:*DOM events. Applies user prefs. Pipeline: startup grace → flap debounce → bulk coalesce (≥3 events / 800 ms collapse to one summary).- Web Notification permission requested from the Settings → Notifications panel via a user-gesture button. State chip reflects granted/denied/default.
- Settings panel — new "Notifications" subtab between Backup and Appearance.
4 IconSelects (
none/snack/os/both) + background-discovery toggle + permission row + Test-notification button. - i18n:
settings.notifications.*andnotifications.*keys in en/ru/zh.
Verification (notifications)
npx tsc --noEmitclean,npm run buildproduces 2.5 MB bundle.ruff check src/ tests/clean. 899/899 pytest pass.- App import smoke-test (
from ledgrab.main import app) loads 233 routes without errors. - Real-hardware test pending — verify on user's network: (1) plug a fresh WLED in → snack toast appears, (2) configure it → next offline transition fires both snack + OS toast, (3) Background-discovery toggle off → no more discovered/lost events.
Out of scope for v1
- Per-device-type granularity (we ship one matrix per event-type, no device-type split)
- Per-device mute list (deferred — user can globally toggle off if noisy)
- Native OS toast via Windows winrt API (Web Notifications cover the use case;
also avoids the
os_notification_listenerfeedback loop) - Notification history panel — could land later as the reserved
alertsdashboard cell
Server shutdown action
Let user choose what happens to LED targets on server shutdown.
- Backend storage:
shutdown_actionindb.settings("stop_targets"default |"nothing") - Backend route:
GET/PUT /api/v1/system/shutdown-actioninsystem_settings.py - Backend schema:
ShutdownActionResponse/Requestinschemas/system.py - Backend wiring: lifespan shutdown in
main.pyreads action, passesrestore_devicesflag toprocessor_manager.stop_all() processor_manager.stop_all(restore_devices: bool = True)— when False, calls publicproc.cancel_task()(defined onTargetProcessor) which awaits cancellation without restoring device state; skips_restore_device_idle_stateloop. No reach into private_taskattribute.- Frontend: hidden
<select>+ IconSelect insettings.htmlGeneral tab (icons viaICON_SQUARE/ICON_CIRCLEfromcore/icons.ts) - Frontend: load/save handlers in
features/settings.ts, wired intoopenSettingsModal() - i18n: en / ru / zh keys for label, hint, item descriptions
- Real-hardware test pending — verify that "nothing" actually leaves a WLED + a serial device on the last frame after
Ctrl+C/SIGTERM.
WebUI Redesign — "Lumenworks" Studio-Console Aesthetic
Full-app UI/UX refresh. Design direction committed to by user 2026-04-24. Mockup lives at server/docs/ui-redesign-mockup.html. Phases are independent and CSS-only where possible — backend untouched.
Phase 1 — Design tokens & font embed
- Embed variable fonts (
server/src/ledgrab/static/fonts/): Manrope (latin + latin-ext + cyrillic + cyrillic-ext), JetBrains Mono (same 4 subsets), Big Shoulders Display (latin + latin-ext). Total +201 KB gzipped, served viaunicode-rangeso only latin paints on first load. fonts.css— declare@font-faceentries for all new families with properunicode-rangesubsetting; keep DM Sans + Orbitron registered for legacy-token callers during migration.base.css— add additive Lumenworks tokens:--font-display/--font-brand/--font-body,--lux-r-*,--lux-hairline,--lux-rule. Both[data-theme="dark"]and[data-theme="light"]define--lux-bg-0…3,--lux-line/-bold,--lux-ink/-dim/-mute/-faint,--ch-signal/-cyan/-magenta/-amber/-coral/-violet,--lux-signal-glow,--lux-shadow-rack. Existing tokens untouched — no visual regression.
Phase 2 — Shell (header → transport bar + channel-strip sidebar)
index.html—.tab-barmoved out of<header>into a new<aside class="sidebar">; wrapped content in.app-body2-col grid (sidebar | main)..transport-centersection added between.header-titleand.header-toolbarwith a placeholder.transport-statuschip ("Ready" → "Armed · N live" wired in Phase 3). All tab-button IDs,data-tabattributes, andonclick="switchTab(…)"handlers preserved.layout.css—<header>rebuilt as the transport bar: 3-column grid (brand | center | toolbar), 60 px fixed height, sticky, gradient bottom rule with channel-color wash..header-title::before/::afterrender the glowing LED brand mark;#server-statusrepositioned as the LED core pip.#server-versionrestyled as a mono-type console badge.sidebar.css(new) — vertical channel-strip navigation. Active tab gets a glowing left stripe + radial tint..sidebar-footcontains a.cpu-meterplate with two live bars (Load, FPS) ready to be JS-bound in Phase 3. Collapses to a 56 px icon rail at ≤1100 px; hides entirely at ≤600 px viadisplay: contentsso.tab-barfalls through tomobile.css's fixed-bottom strip unchanged.all.css— new sidebar import after layout.base.css— body font-family switched tovar(--font-body)which resolves to Manrope (with DM Sans + system fallbacks). Addedfont-feature-settingsfor stylistic set + alternate 1.- Locale additions:
sidebar.workspaces,sidebar.load,sidebar.fps,transport.status.ready,transport.status.armedin en/ru/zh. - Tutorial + auth selectors (
header .header-title,#tab-btn-*,.tab-barquerySelector,a.header-link[href="/docs"], onclick markers on theme/settings/search) all survive the move. - JS: bind
.cpu-meter+.transport-statuschip to existingperformanceWebSocket / poller. Done as part of Phase 3. - Tablet-range visual polish pass once other phases render (some tabs currently have their own internal sticky headers that may overlap the transport bar on narrow viewports).
Phase 3 — Dashboard hero + module redesign
cards.css—.cardgets rack-module treatment: channel stripe on left edge (color-coded viadata-card-type+.ch-*utility classes),::aftercorner bracket in top-right, mono-typed metric labels planned for Phase 4. Running cards glow the stripe brighter + emit asignalFlowkeyframe strip along the bottom edge.- Removed the
@property --border-anglerotating conic-gradient border (retired the WebKit mask workaround + light-theme variant + fallback for@supports not (mask-composite: exclude)). Replaced with the signal-flow strip — one animated linear-gradient on a 2 px line, no GPU layer compositing per card. dashboard.css—.dashboard-targetrows pick up the same channel stripe + signal-flow treatment. Section headers now use mono caps with a channel-green underline accent. Metric values use mono with tabular numerics; labels use silkscreened micro-caps.- Skeleton-card rewritten: left hairline + corner bracket so it reads
as "loading module" instead of a generic flashing block.
skeletonShimmergradient replaces the old opacity-pulse on--text-color. _updateSidebarMeterbinds CPU% (Load) and app-CPU share (FPS) to the sidebar meter plate on every perf poll._updateTransportStatusupdates the transport chip ("Ready" → "Armed · N live") whenever the dashboard's running-target set is recomputed..hero4-cell readout row (Active Patches / Throughput / CPU / Latency + inline sparklines) — CSS tokens + layout are ready; HTML render deferred until the dashboard JS is refactored to emit it (Phase 3b, non-blocking).
Phase 4 — Other tabs adopt module language
tree-nav.css— trigger pill gets a channel stripe on its left edge (glows + widens when open). Trigger title uses mono-uppercase with wide letter-spacing. Dropdown panel has a gradient channel-accent rule across its top edge. Group headers use silkscreened micro-caps with a small square marker instead of the old bold-uppercase. Active leaf has a pulsing LED pip on the left and a channel tint behind it. Count badges switched to mono tabular-nums in 2-px-radius pills..subtab-section-header— channel-green underline accent + mono micro-caps. Consistent with the dashboard-section pattern so the whole app shares one section-header language..stream-tab-btnsub-tabs — mono uppercase with wide tracking, active tab shows channel-green underline + glowing count badge..perf-chart-card— channel stripe on the left (replaces oldborder-topaccent). Per-metric accents swapped to channel palette (--ch-coralfor CPU,--ch-violetfor RAM,--ch-signalfor GPU,--ch-amberfor temp). Corner bracket added. Metric values pick uptabular-nums+ a soft glow.cards.css— channel-color mapping extended to attributes the JS already emits (data-target-id→ green,data-stream-id→ cyan,data-audio-source-id→ magenta,data-automation-id/data-scene-id→ violet). No JS changes required; cards pick up their correct stripe automatically on the Targets/Sources/Automations tabs.- Graph editor — toolbar gets a gradient background + hairline + rack shadow + backdrop blur. Canvas and nodes untouched.
.template-card— Lumenworks treatment (channel stripe on left, corner bracket top-right, hairline border, hover lift + stripe glow). Brings Inputs (streams / capture / pp / cspt / pattern templates) and Integrations (HA / MQTT / weather / value / sync-clock / game-integration cards) up to the same visual language as.cardand.dashboard-target.cards.css— channel mapping extended to.template-card. Direct attr hooks fordata-stream-id/data-template-id/data-pp-template-id(cyan),data-cspt-id/data-pattern-template-id(signal),data-audio-template-id/data-apt-id(magenta). Section-scoped hooks via[data-card-section="…"]for cards that share a genericdata-id(HA / MQTT / weather / value → cyan; game-integrations → amber; sync-clocks → violet; HA-light-targets → signal). No JS changes — uses the section markupCardSectionalready emits.- Graph editor nodes — body fill
--lux-bg-1with hairline stroke, hover bold-line, selected/running stroke--ch-signalwith drop-shadow glow. Title font switched from DM Sans to--font-display; subtitle to mono uppercase wide-tracking. Port-drop-target glow recoloured to--ch-signal. Port labels adopt the mono caption treatment. Grid dots use--lux-line. Running gradient stops switched from--primary-color/--success-colorto channel palette (signal → cyan → signal).
Phase 5 — Modal restyle
modal.css— backdrop gains a radial dim + 6 px blur for stronger separation..modal-contentgets a gradient background + hairline + deep rack shadow. Channel-accent rule across the top edge driven by--modal-ch(per-modal override). Corner bracket bottom-right on desktop..modal-headergains a vertical channel-color stripe to the left of the title;.modal-footerpicks up a hairline divider.- Per-modal channel mapping by modal ID: - Target editors → green - Input/Source editors → cyan - Audio editors → magenta - Automation / Scene / Game editors → violet - Settings / API key / Setup / Notifications → amber - Confirm dialog → coral
components.css— inputs use hairline borders, tabular-nums mono forinput[type="number"], channel-green focus ring + glow. Buttons use mono-uppercase type, signal-glow on primary, coral-glow on danger.<select>audit deferred (project already enforces via CLAUDE.md rule + IconSelect/EntitySelect wrappers).
Phase 6 — Mobile dedicated shell
mobile.css(existing file, not forked) — fixed-bottom.tab-barpromoted to full Lumenworks treatment: gradient background + hairline divider at top + channel-accent rule matching the transport-bar bottom. Active tab gets an LED pip above the icon and a channel-tint background. Tab labels + badges use mono uppercase to match the rest of the app. Phone (≤600 px): modal corner-bracket hidden (fullscreen modals), modal-header stripe slimmed to 18 px.- Phase 2's layout.css already strips the transport-center on phones
and collapses the sidebar via
display: contents, so the mobile shell automatically routes the tab-bar to the bottom without a separate JS hook. - [WONTDO] Fork into
mobile-shell.css— keeping changes inmobile.csssince the cascade was already organized by viewport. A rename adds churn without improving maintainability.
Phase 7 — Microcopy + retire legacy
- Locale rename:
targets.title+dashboard.section.targets→ "Channels" (en) / "Каналы" (ru) / "通道" (zh);streams.title→ "Inputs" / "Входы" / "输入". Automations kept as-is (Automations + Scenes is a meaningful distinction; "Patches" would conflate them). Internal tab keys (dashboard/automations/targets/streams/integrations/graph) unchanged so no JS or localStorage migration needed. - Ambient WebGL background — default is already
off; kept the toggle button and localStorage preference so users who want the shader can turn it on. No entry-point change needed:data-bg-animis initialized from localStorage withofffallback. - [DEFERRED] Delete DM Sans + legacy color tokens — would cascade through
every file that reads
--primary-color/--text-coloretc. Safer as a separate cleanup PR after the new design has soaked. - [WONTDO] Delete
mobile.css— Phase 6 kept the filename.
Dashboard Customization
Per-account dashboard layout — slide-in Customize panel lets users
toggle section / perf-cell visibility, reorder via drag, change density,
pick presets, and import/export the layout as JSON. Server-synced via
db.get_setting('dashboard_layout') so settings follow the user.
js/features/dashboard-layout.ts— schema (open registry of section / perf-cell keys so v1.1 cards slot in with no migration), defaults, 5 built-in presets (Studio/Operator/Showrunner/Diagnostics/TV), localStorage cache + server sync, legacy-key migration fromdashboard_collapsed,perfMetricsMode,perfChartColor_*.api/routes/preferences.py—GET/PUT/DELETE /api/v1/preferences/dashboard-layout. Treats payload as opaque (frontend owns the schema); validates only that body is an object with a numericversion. 6 pytest tests intests/test_preferences_api.pycover round-trip, default-empty, validation, delete, and unknown-field passthrough.js/features/dashboard.ts— sections rendered into a fragment map, then assembled in layout-driven order; perf section stays pinned top (chart-persistence reasons) but its visibility is layout- driven. Layout-change subscription invalidates the in-place-update optimization so density / order / visibility changes always rebuild section HTML.js/features/perf-charts.ts—renderPerfSection()iteratesgetOrderedPerfCells(); existing legacysetPerfModewrites through to the layout so the global toggle and the customize panel stay in sync.js/features/dashboard-customize.ts+css/dashboard-customize.css— slide-in panel, hand-rolled HTML5 drag-and-drop reorder, ↑/↓ buttons for keyboard / TV remote, debounced (300 ms) autosave, live preview while open. Reset / export / import actions.- i18n keys for
dashboard.customize.*in en/ru/zh. - (v1.1) Audio meters section — peak / RMS / BPM bars per audio
source. Schema key
audio-metersalready reserved. - (v1.1) Alerts section — quiet by default, loud on issues.
Reserved key
alerts. - (v1.1) Live LED preview strip per running device. Reserved
key
led-preview. - (v1.1) Source thumbnails grid (1 fps multiviewer). Reserved
key
source-thumbs. - (v1.2) Pinned section (user-curated mix of targets / scenes /
devices). Reserved key
pinned. - (v1.2) Patch/flow map — read-only mini graph of routing.
Reserved key
flow.
BLE LED Controller Support (SP110E / Triones / Zengge / Govee)
Add support for Bluetooth Low Energy LED controllers driven by mobile apps like "LED Hue", HappyLighting, iLightsIn. Whole-strip ambient-color output only — these protocols don't support per-pixel streaming.
- Add
bleak>=0.22as optional extra[ble]inserver/pyproject.toml(desktop-only, NOT in androidbuild.gradle.kts) core/devices/ble_transport.py— bleak wrapper: scan, connect, write-with/without-responsecore/devices/ble_protocols/package__init__.py—BLEProtocoldataclass + registry (family → encoder)sp110e.py— SP110E / SP108E (service FFE0, char FFE1,RR GG BB 00 1Estatic-color frame)triones.py— Triones / HappyLighting / LEDnet (service FFE5, char FFE9,7E 07 05 03 RR GG BB 10 EF)zengge.py— Zengge / iLightsIn (service FFE0, framing56 RR GG BB 00 F0 AA)govee.py— Govee unencrypted framed protocol (AES keyed variants — marked experimental)
core/devices/ble_client.py— unifiedBLEClient(LEDClient)— picks protocol byble_family, averages strip → one color, drops duplicate frames, rate-limits to BLE connection intervalcore/devices/ble_provider.py—BLEDeviceProvider+ discovery viaBleakScanner- Register in
core/devices/led_client.py::_register_builtin_providers(guardedtry/except ImportError) - Storage:
ble_family,ble_govee_keyfields threaded throughDevice.__init__/to_dict/from_dict/_UPDATABLE_FIELDS/create_device - Schemas: BLE fields on
DeviceCreate,DeviceUpdate,DeviceResponse - Routes: BLE fields propagated through create/update in
api/routes/devices.py+_device_to_response - ProcessorManager:
ble_family/ble_govee_keyadded to_DEVICE_FIELD_DEFAULTSandDeviceInfo; passed throughwled_target_processor.pyandgroup_client.pytocreate_led_client - Tests: 21 protocol encoder unit tests + 16 BLEClient fake-transport tests — all passing, 814 total tests still green
- Frontend: BLE option in the device type picker with a bluetooth Lucide icon; add-device modal shows a 4-option
IconSelectfor protocol family (SP110E / Triones / Zengge / Govee) with a Govee-only AES key field that auto-hides for the other three families; URL label/placeholder/hint adapt toble://<address>pattern; submit payload carriesble_family(+ optionalble_govee_key); clone flow pre-fills family and key; modal dirty-check snapshots the new fields; network scan button now also discovers BLE peripherals via the existing/api/v1/devices/discover?device_type=bleendpoint - Frontend:
isBleDevicehelper incore/api.ts;ICON_BLUETOOTH+ICON_LIGHTBULBconstants incore/icons.ts;bluetoothpath incore/icon-paths.ts; i18n keys inen.json/ru.json/zh.json; TypeScript compiles; esbuild bundle rebuilt - Android BLE via Kotlin bridge —
BleBridge.ktsingleton (scan/connect/write/disconnect);android_ble_transport.pyPython wrapper;make_transport()factory inble_transport.pyauto-selects backend;BleBridge.init()called fromLedGrabApp.onCreate; BLE permissions inAndroidManifest.xml - Govee per-model AES key —
_encrypt_govee_frame()inble_client.pyuses AES-128-ECB fromcryptography; key validated onBLEClientconstruction; applied to bothsend_pixelsandset_power; 8 new AES unit tests
Android — Restore Multi-ABI Wheels
During emulator testing, we switched the build to x86 only (see android/app/build.gradle.kts abiFilters) to avoid having to keep the arm64-v8a / x86_64 pydantic-core wheels current. Before shipping, restore all three ABIs:
- Rebuild
pydantic-corewheels for all three ABIs with the current SOABI + libpython linking settings (android/build-scripts/build-pydantic-core.sh— now supportsarm64,x86_64,x86args; defaults to all three). - Verify wheels: all three now list
libpython3.11.soinNEEDED(llvm-readelf -d), automated in the build script. - Restored
abiFilters += listOf("arm64-v8a", "x86_64", "x86")inbuild.gradle.kts. Multi-ABI debug APK builds cleanly (~99 MB). - Re-test on real ARM64 Android TV hardware (still pending — only emulator-verified build).
Build cache + scripts live in android/build-scripts/ and android/.build-cache/ (junction host + sysconfigdata for each ABI).
Android CI Pipeline
Build the Android APK automatically on push/tag.
- Generate Gradle wrapper (
gradlew) and commit it - Create CI workflow (
.gitea/workflows/build-android.yml)- JDK 17 + Android SDK + NDK setup
- Python 3.11 for Chaquopy build
- Recreate the directory junction via
ln -son Linux CI ./gradlew assembleDebugon master push,assembleReleaseonv*tags (if signing secrets set)- Uploads APK as CI artifact; attaches to Gitea release on tag push
- Commit pre-built pydantic-core wheels to
android/wheels/(arm64, x86, x86_64) - APK signing for release builds — conditional signing config reads keystore from env vars (
ANDROID_KEYSTORE_PATH/_PASSWORD/_ALIAS/_KEY_PASSWORD), falls back to debug signing locally - Provision a real keystore and add the four CI secrets:
ANDROID_KEYSTORE_BASE64(base64-encoded .jks)ANDROID_KEYSTORE_PASSWORDANDROID_KEY_ALIASANDROID_KEY_PASSWORD
- Add
LedGrab-{tag}-android-release.apkrow to the release description table in.gitea/workflows/release.yml→create-releasejob - Verify the CI workflow passes end-to-end with the now-restored multi-ABI build (larger APK, longer Android build step)
Android Root Capture (No Permission Dialog, No System Indicator)
MediaProjection shows a mandatory system overlay/indicator while capturing — unavoidable on stock Android. Many cheap Android TV boxes ship pre-rooted, so an alternative root-only path gives much better UX.
- Root detection —
Root.ktchecks commonsubinary paths and, on demand, runssu -c idto actually prove UID 0. First call triggers Magisk's grant dialog; grant is cached per session. Exposed to Python via Chaquopy. RootScreenrecord.kt— spawnssu -c screenrecord --output-format=h264 --size=WxH -, feeds the H.264 stdout through a MediaCodec decoder whose output Surface is wired into an ImageReader (RGBA_8888, row-stride-aware). Decoded frames reach the Python pipeline viaPythonBridge.pushRootFrame.- Python-side
RootScreenrecordEngine(core/capture_engines/root_screenrecord_engine.py) mirrorsMediaProjectionEnginewithENGINE_PRIORITY=110(> MediaProjection's 100) so the factory picks it automatically when available. MainActivitytriesRoot.requestGrant()before launching the MediaProjection consent flow — on rooted devices the consent dialog is skipped entirely.CaptureServicehas acreateRootIntent()entry point that bypasses the MediaProjection path.- Fallback: if
Root.requestGrant()returns false (no root, user denied, orsutimeout) the existing MediaProjection flow runs unchanged. - Real-hardware test pending — need to verify on the user's Magisk'd TV box that: (1) grant dialog appears once, (2) frames actually flow through MediaCodec without the Android 14 capture indicator showing, (3) stop/start cycle terminates the
suprocess cleanly. - [WONTDO]
SurfaceControl.screenshot()via reflection — renamed/moved across API 28/29/30/33, hidden-API blocklist varies by release, even rooted apps hit it; days of maintenance for a marginal latency win over the screenrecord path. Not worth it. - [WONTDO]
adb screencapfallback — full-PNG-per-frame pipeline is slower than MediaProjection, no value as a last resort.
Known projects using the screenrecord approach for reference: scrcpy (over ADB), scrcpy-hidden-api, shizuku.
Android Autostart on Boot
Boot-time, zero-interaction startup so LedGrab always has display capture and control on rooted TV boxes.
- Manifest: declare
RECEIVE_BOOT_COMPLETED,REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,WAKE_LOCK; register.BootReceiverforBOOT_COMPLETED/LOCKED_BOOT_COMPLETED/MY_PACKAGE_REPLACED. BootReceiver.kt— gated byAutostartPrefs+Root.looksRooted(); dispatchesCaptureService.createRootIntent()viaContextCompat.startForegroundService. Unrooted devices are a no-op because MediaProjection consent cannot be bypassed silently.AutostartPrefs.kt— thin SharedPreferences wrapper, defaults to enabled. Shown as a CheckBox on the stopped panel; greyed out on unrooted devices.CaptureServicereturnsSTART_REDELIVER_INTENTfor root-mode intents so the OS can cleanly restart the service after being killed (token-free path). MediaProjection-mode keepsSTART_NOT_STICKY— restart is pointless with a dead consent token.isRunningrace: moved assignment to afterstartForegroundsucceeds, resets on exception;onStartCommandwrapsstartForegroundin try/catch and stops the service cleanly if the FG transition fails.- Root-capture watchdog: coroutine on
serviceScopechecksRootScreenrecord.framesDeliveredevery 5s after a 5s grace. Respawns the pipeline (reusing the existing Python bridge) on stall, caps at 3 consecutive restarts before giving up. RootScreenrecord.framesDeliveredexposed as a property backed byAtomicInteger(was@Volatile var framesDelivered = 0with non-atomic+= 1).ScreenCaptureacceptsonProjectionStoppedlambda —MediaProjection.Callback.onStopnow tears the whole service down instead of leaving a stale FG notification.MainActivitywires the autostart toggle toAutostartPrefs; enabling it promptsREQUEST_IGNORE_BATTERY_OPTIMIZATIONSso Doze doesn't kill the FG service on phones.versionCodederived fromgit rev-list --count HEAD(orANDROID_VERSION_CODEenv var in CI). Was stuck at 1 — sideload updates were silently refusing to install.- Real-hardware test pending — need to verify on the user's Magisk'd TV box that: (1) boot-time autostart dispatches the service without UI, (2) capture indicator still absent under root mode post-reboot, (3) watchdog respawns the pipeline when
screenrecordis externally killed, (4) sideload upgrade installs cleanly after the versionCode bump. - Optional follow-up: "kiosk" mode — add
<category android:name="android.intent.category.HOME" />toMainActivityso power users can set LedGrab as the default TV launcher for truly always-running behavior.
Android USB Serial Support
Drive USB LED controllers (APA102, WS2812) connected directly to the Android TV box via USB-to-serial adapters.
- Added
com.github.mik3y:usb-serial-for-android:3.8.1(via JitPack) toandroid/app/build.gradle.kts. - Kotlin
UsbSerialBridgesingleton (android/app/src/main/java/com/ledgrab/android/UsbSerialBridge.kt) — exposeslistDevices(),open(vid, pid, serial, baud),write(handle, ByteArray),close(handle). Permission request fires automatically fromopen()when the user hasn't granted access yet. Handles are opaque integers, port map is synchronized, so Python threads can share one bridge. - Python
AndroidSerialTransportinserver/src/ledgrab/core/devices/android_serial_transport.pydrives the bridge through Chaquopy.SerialTransportProtocol +PySerialTransport+list_serial_ports()factory live inserial_transport.py;AdalightClientandSerialDeviceProvidernow go through the abstraction instead of importingpyserialdirectly. - URL scheme extended:
usb:VID:PID[:serial][@baud]on Android alongside the existingCOM3[:baud]//dev/ttyUSB0[:baud]desktop paths. - App initializes the bridge on startup (
LedGrabApp.onCreate→UsbSerialBridge.init(this)); manifest declaresuses-feature android.hardware.usb.host. - Real-device test pending — no USB-serial hardware on dev machine. Need to verify on a TV box with CH340, CP2102, or FTDI adapter.
- Document supported USB LED controllers in README (once real-device test passes).
- Optional: auto-launch the app when a known USB-serial adapter is plugged in (intent-filter on
USB_DEVICE_ATTACHED+res/xml/device_filter.xml). Skipped in v1 — users can just open LedGrab and hit "Discover". - ESP-NOW client (
espnow_client.py/espnow_provider.py) now routes throughSerialTransport—open_transport()for the gateway serial link,list_serial_ports()+port_exists()for discovery/validation. Works transparently withusb:VID:PIDURLs on Android. (Gateway protocol is write-only, so noread()extension was needed after all.)
Performance Metrics Abstraction
MetricsProviderprotocol + dataclass DTOs (MemorySnapshot,ProcessSnapshot) live inserver/src/ledgrab/utils/metrics/types.py. Each provider has its own module:psutil_provider.py,null_provider.py,android_provider.py.- Factory
get_metrics_provider()inutils/metrics/__init__.pyselects Android → psutil → Null.psutilimport is now confined to one place. api/routes/system.pyandcore/processing/metrics_history.pyuse the provider; no moreif psutil is not Noneguards in the hot paths.- Android
/proc-backed provider implemented (/proc/stat,/proc/meminfo,/proc/self/stat,/proc/self/status). Carries previous-sample state for delta-based CPU%; degrades to zeros if any/procfile is locked down. 12 unit tests cover both desktop and Android paths.
Android Performance Metrics — Future Enhancements
Beyond the /proc-based AndroidMetricsProvider that's now in place:
- Device battery + thermal-zone readings (
/sys/class/power_supply/battery/{capacity,temp},/sys/class/thermal/thermal_zone*/tempfiltered by zone type). Surfaced throughMetricsProvider.thermals(),PerformanceResponse.{cpu_temp_c,battery_percent,battery_temp_c}, the metrics-history snapshot, and a new dashboard temperature chart that hides itself when the backend reports null. GPU card now hides (no "unavailable" placeholder) when no GPU is present. - [WONTDO] Optional: app-specific memory via
Debug.getMemoryInfo()through a Kotlin → Python Chaquopy bridge (more accurate thanVmRSSfor split-app-process accounting) - [WONTDO] Optional: GPU usage via
/sys/class/kgsl/kgsl-3d0/gpubusyon Adreno, Mali-specific paths for Mali GPUs
Refactor: Per-Provider Device Configs
Replace flat DeviceInfo + **kwargs provider contract with a discriminated union of typed per-provider config dataclasses.
- Phase 1 —
DeviceConfighierarchy +Device.to_config()(non-breaking, additive only) - Phases 2+3 — narrow
LEDDeviceProvider.create_clientto typed configs; migrate 3 call sites; deleteDeviceInfo+_get_device_info+_DEVICE_FIELD_DEFAULTS(single PR) - Phase 4 — migrate
tests/test_group_device.pytoGroupConfig/ProviderDeps; remove legacyGroupLEDClientinit path; 47-test config suite with 100% coverage ondevice_config.py - Phase 5 (separate PR, optional) — Pydantic v2 discriminated union in
api/schemas/devices.py; scope frontend POST/PATCH payloads bydevice_type
Expand device support (Phase 1: open protocols)
Branch: feat/expand-device-support.
Goal: maximize the universe of LED controllers LedGrab can drive by adding aggregator + open-protocol providers in roughly-this order. Each driver follows the established LEDDeviceProvider + *Config + tests pattern.
Phase 1.1 — Standalone DDP target ✅ shipped (commit 8f1140a)
DDP packet layer (previously WLED-internal) promoted to a first-class device type. Pixelblaze, ESPixelStick, xLights/Falcon endpoints, and generic DDP receivers are now drivable directly without WLED in the path.
Phase 1.2 — Yeelight LAN
Xiaomi/Yeelight bulbs, port 55443 TCP JSON. Direct protocol (no
python-yeelight dependency — implementation is ~200 lines).
YeelightConfigdataclass withyeelight_min_interval_msrate limitYeelightClientincore/devices/yeelight_client.py— TCP JSON-RPC, averaging single-pixel adapter, client-side rate gate- SSDP-style discovery (Yeelight's variant on
239.255.255.250:1982) YeelightDeviceProviderwith validate/health/discover- Storage + API schemas + route handler wiring
- 34 unit tests (URL parsing, RGB packing, averaging, rate limit, SSDP parsing, provider validate/discover, Device.to_config round-trip)
- Frontend: Yeelight in device-type picker + edit form (spawned to a
frontend-designsubagent) - Locale strings (en/ru/zh)
- Music mode (~60 Hz updates via reverse-TCP) — follow-up, current MVP caps at ~2 Hz via the client-side rate gate
Phase 1.3 — WiZ Connected
Philips' UDP-local budget tier. Port 38899 JSON UDP.
WiZConfig+WiZClient+WiZDeviceProvider- UDP broadcast discovery on 255.255.255.255:38899 with the standard
registrationenvelope; replies parsed for IP+MAC. - Sync
send_pixels_fastfor the hot loop (UDP is fire-and-forget, no async needed). 50 ms default min interval → ~20 Hz cap. - Health check sends
getPilotand waits for any reply. - Storage + API schemas + route handler wiring
- 36 unit tests
- Frontend: WiZ in device-type picker + edit form
- Locale strings (en/ru/zh)
Phase 2 — Unified discovery + pairing UX layer
After phase 1 the codebase will have 3 fresh examples of "ping the LAN, listen for replies, present a list". Factor that out into a generic discovery scaffold + a "first-run pairing" UX component before adding Tuya/Govee/etc., which each need a one-time pairing dance.
- [WONTDO] Generic
NetworkDiscoveryService— the existing/api/v1/devices/discoverroute already runs all providers in parallel viaasyncio.gather(return_exceptions=True). Extracting it would not unlock anything; revisit only if discovery cadence/dedup becomes a real complaint. - [WONTDO] Unified scan UI — already exists; one "Scan network" button triggers the cross-provider fan-out.
- Reusable pair-device scaffold (the actually-needed piece).
Backend:
LEDDeviceProvider.pair_device(url)abstract method withPairingNotReadysentinel;POST /api/v1/devices/pairendpoint with status-code mapping (200/400/409/422/502); 8 route tests covering every outcome. Frontend:templates/modals/pair-device.htmlfive-state modal (idle / pairing / not-ready / success / failed) with a 30-second SVG progress ring; reusablestatic/js/features/pairing-flow.tsexposingrunPairingFlow({deviceType, url}) → Promise<{fields}>withPairingCancelledsentinel; locale strings in en/ru/zh. No driver uses it yet — Nanoleaf will be the first concrete consumer.
Phase 3 — Big aggregator unlocks
- ESPHome native API (
aioesphomeapi) - Tuya Local (
tinytuya) — biggest single market unlock; needs the pairing UX from Phase 2 - Matter over IP (forward-looking)
- Hyperion JSON downstream
Phase 4 — Major consumer brands
- LIFX LAN — UDP binary protocol on port 56700; RGB→HSBK 16-bit conversion; broadcast discovery via GetService/StateService probe; 47 unit tests. Single-pixel adapter shape, identical to WiZ structurally. Frontend wired via subagent.
- Govee LAN API — UDP JSON on port 4003 (control) + 4002
(responses) + 4001 (multicast discovery on 239.255.255.250).
Single-pixel
colorwccommand withcolorTemInKelvin=0for RGB mode. Per-device "LAN Control" toggle required in Govee Home app. 40 unit tests. Frontend wired via subagent. - Nanoleaf OpenAPI — Light Panels / Canvas / Shapes / Lines /
Elements via HTTP REST on port 16021. First concrete user of
the pairing-UX scaffold from Phase 2. mDNS discovery via
_nanoleafapi._tcp. Single-pixel adapter (averaged strip → HSBPUT /state). Auth token encrypted at rest via_enc/_dec. 42 unit tests covering URL parsing, RGB→HSB conversion, pairing handshake (200/403/500/missing-token/transport-error), state mutations, brightness clamping, Device.to_config round-trip including encrypted-token roundtrip. - Twinkly — multi-pixel + login flow; deferred
- [WONTDO] Mi-Light / MiBoxer UDP gateway — the recommended path for
modern Mi-Light deployments is
esp8266_milight_hubfirmware → MQTT, which LedGrab already supports through the existing MQTT device target (commit530316c). Native V6 driver would be ~400 lines + finicky session protocol + custom 1-byte hue table; the marginal benefit over the MQTT path is small. Revisit if a user complaint surfaces.
Phase 5 — Open pixel protocols (cheap completionism)
- OPC (Open Pixel Control) — TCP, port 7890, 4-byte header
[channel][cmd][len_hi][len_lo]+ RGB body. Channel 0 broadcasts. Single-pixel-strip protocol, no discovery, no pairing. 36 unit tests. Fadecandy + xLights + hobbyist receivers reachable. - TPM2.net
Phase 6 — PC gaming RGB completion
- Corsair iCUE SDK
- Logitech LIGHTSYNC
- ASUS Aura SDK
Phase 7 — Proprietary USB HID ambient kits
- Generic HID-ambient framework + VID/PID registry
- First reverse-engineered target (probably Govee Immersion / DreamView)
Cleanup + verification
_average_colorextraction (commitcc87fba). Six identical copies (Yeelight / WiZ / LIFX / Govee / Nanoleaf / BLE) collapsed intocore/devices/pixel_reduce.average_color. Net -76 lines. Hue is out by design — its Entertainment API addresses up to seven lights individually.- Pre-merge verification pass. 1358 pytest tests pass; ruff
clean across all device modules and tests; black clean against
the pre-commit-pinned 24.10.0;
npx tsc --noEmitclean; bundle compiles. - Pre-merge code review (subagent) — surfaced 2 CRITICAL + 4 HIGH + 3 MEDIUM + 3 LOW findings.
- All review findings fixed (commits
7736bc6+0e3ae78): - CRITICAL #1: missingurl_scheme.py/net_classify.pycommitted (4 files / 557 lines). - CRITICAL #2:update_deviceno longer re-encrypts secrets in memory via theto_dict()round-trip (usesvars()directly). - HIGH #3:nanoleaf_token/hue_username/hue_client_keystripped fromDeviceResponse; replaced with paired-flag booleans. Frontend updated. - HIGH #4:validate_lan_host()rejects literal public IPs at each driver'svalidate_device+pair_device. - HIGH #5:_dec()failures clear the field and log, not crash the row. - HIGH #6: update route now rstrip's URL for all device types. - MEDIUM #7: Govee discovery serialized viaasyncio.Lock. - MEDIUM #8: Nanoleaf mDNS browser cleanup moved tofinally. - MEDIUM #9: pair endpoint sanitizes URL userinfo in logs. - LOW: Nanoleaf.portproperty added; pair-then-create E2E test added. - Tests: 1379 pass (+21 regression tests).
Graph editor — "full control of wiring via graph" (in progress)
Goal: make the visual graph a first-class wiring control surface, not just a viewer. Driven by the ULTRA-DEEP review (findings A1–A5, B1–B6, C1–C6, D1–D6).
Done (NOT yet committed — awaiting review/commit)
- A1 Undo/redo wired to connect/detach/move (was dead code); inverse ops throw on failure so the stack can't silently desync.
- A2 Manual node layout persists to
localStorage(graph_node_positions), cleared on relayout. - A3 Scene-preset disambiguation — deactivation scene now reachable via a field picker (was always picking the first match).
- B6 Edge field labels (revealed on zoom ≥ 0.9).
- C3 Health overlay — broken refs (referrer exists, target missing), dependency cycles, orphans; node warning badges + an issues toolbar button.
- D1
GET /api/v1/graph/schema— authoritative connectable-field registry (api/graph_schema.py, pure + unit-tested). - D2
GET /api/v1/graph(nodes+edges+validation) andGET /api/v1/graph/dependents/{kind}/{id}. - D4
POST /api/v1/graph/validate-connection— existence + source-kind + cycle pre-flight; frontend validates before every write (fails open if the endpoint is unreachable). List/double-nested fields rejected. - B2 Drop-on-node connect — empty top-level slots are now wireable (drop a source onto any compatible node body, not just an existing port).
- C4 Overwrite-occupied-slot confirm + delete-with-dependents warning (single delete only; bulk keeps the batch confirm).
- D5 Create-and-connect — drag a port onto empty canvas → pick a compatible new entity kind → it's created and auto-wired (kind-scoped watcher).
- D6 (read-only half) "Export graph (JSON)" toolbar action.
- Custom per-entity
icon+icon_colornow render on graph nodes (parity with custom node colours; fallback to kind/subtype glyph). - B1 Edit single-level BindableFloat value slots from the graph
(
brightness,smoothing,intensity,scale,speed, … on color_strip_source;brightness/transitionon output_target). Subtype-safe (only offers slots the target entity actually has). Writes the partial{ <slot>: { source_id } }payload → backendBindable*.apply_updatemerges, preserving the static value. Verified data-safe (nofrom_raw/value-reset path). - Render the two functional value-source references
buildGraphwas missing —value_source.value_source_id(gradient_map → inner value source) andvalue_source.color_strip_source_id(css_extract → strip). Both are runtime- resolved and already drag-editable; now visible/detachable in the graph. - B4 foundation: backend schema now authoritative about graph-editability
(
is_editable()+editableflag in/graph/schema);validate-connectionhardened to reject non-editable fields (colour/list/double-nested), not just lists. - B4 drift guard + gap fixes:
checkSchemaDrift()(graph-connections.ts) warns once if the frontendCONNECTION_MAPeditable set diverges from/graph/schema(the automated "10-step checklist"). Surfacing it found 3 real gaps; fixed 2:color_strip_source.input_source_id+processing_template_idare now drag-editable (processed-strip wiring;apply_updateis partial-safe). The 3rd —device.default_css_processing_template_id— is intentionally NOT drag-editable (the device PUT route isn't partial-safe; a one-field PUT could null the URL) and is in the drift-check exclude set. Also broadened_availableMatchesto hide any slot the target entity doesn't expose (subtype-accurate; refs are always-emitted so empty slots stay wireable). Review also caught a deadoutput_target.picture_source_idslot (no output target stores it — not a field/schema, never emitted) — removed from both registries +buildGraph. - Comprehensive review pass (4 subagents: backend/frontend-core/orchestrator/security).
Findings fixed:
- CRITICAL (security):
GET /api/v1/graphleaked plaintext webhook tokens (asdictrecursedAutomation.rules[].token, an auth-equivalent secret). Fixed with field-projection —serialize_entity_for_graph()/graph_field_roots()project each entity to only{id, name, subtype, reference-roots}; secrets can't survive. Added a structural regression test asserting no projection root is secret-bearing for any kind (drift-proof boundary) + a token-drop test. - MEDIUM: added missingvalue_source.clock_id(AnimatedColorValueSource → sync_clock) to the backend registry for topology/dependents completeness (drift-excluded on the frontend — value-source PUT needs asource_typediscriminator, so it's editor-only). - MEDIUM/LOW:CSS.escapeon the markIssues id selector; grouped/clarified_DRIFT_EXCLUDE; fixed the stale_availableMatchesJSDoc; documented thecheckSchemaDriftforward-reference. Orchestrator + frontend-core + security: APPROVE. - Verification:
npm --prefix server run typecheck+run buildclean; ruff clean; graph backend tests 35 pass; full backend suite green. ~8 code-review passes, all CRITICAL/HIGH findings fixed.
Left to do (deferred)
- BindableColor slots — CHECKED, decision: keep read-only (won't fix).
Value sources are scalar-only (
ValueStream.get_value() -> float) and every colour consumer (color_strip/single.py,effect_stream.py) reads the static RGB viabcolor(), ignoringsource_id. So a value_source cannot drive a colour — wiringcolor/color_peak/… would be a dead binding. Documented inapi/graph_schema.pynext to the BindableColor entries. (Would only become viable if a colour-producing value-source type is added.) - [~] B4 — delete the frontend
CONNECTION_MAPduplication. - [x] Foundation done: the backend schema now carries an authoritativeeditableflag per field (is_editable()inapi/graph_schema.py, mirroring the frontend_isEditable: top-level refs + single-level BindableFloat slots; NOT colour/list/double-nested).validate-connectionis hardened to reject any non-editable field (was list-only).editableis surfaced in/graph/schema. - [ ] Remaining (the refactor): frontend fetches/graph/schemaon load and derives connection metadata + edges from it (port theextract_refsdot-path/list grammar to TS), keeping only a tinykind → {endpoint, cache}write-routing table; then delete the field-levelCONNECTION_MAP+ thebuildGraphedge loops (graph-connections.ts / graph-layout.ts). Removes the 10-step sync checklist incontexts/graph-editor.md. A backend apply-write endpoint is NOT required — keep the proven per-entity PUT. Risk: regressing drag-connect/bindable; keep a dev drift-check (frontend editable set vs/graph/schema) during the transition. Note: frontendCONNECTION_MAPalso has inertha_source_id/gradient_identries (no graph node kind) — drop them, the backend schema already omits them. - D6 — blueprint import/instantiate. Export exists; the apply half (serialize a selected subgraph's topology + entities, re-import with id remapping, conflict handling) is large and data-integrity-sensitive (see Data Migration Policy in CLAUDE.md). Scope as its own feature.
- List-slot editing (composite
layers[], mappedzones[], scene presettargets[]) — needs an element index in the write + validate paths (validate_connectioncurrently rejects list fields). Edit via entity modal for now.
Notes / decisions
- The backend
CONNECTION_SCHEMA(api/graph_schema.py) is the authoritative superset; it already declares the bindable + list + value_source-chain edges. The frontendCONNECTION_MAPstill owns write-routing (endpoint/cache) — that's the only reason it survives (see B4). - Bindable edges render dashed (
.graph-edge-nested) but ARE editable — the dashed style intentionally distinguishes value bindings from structural edges. validate-connectionanddependentsfail open/safe on the frontend so the graph keeps working against an older server without these endpoints.