4b65005823
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.
740 lines
46 KiB
Markdown
740 lines
46 KiB
Markdown
# LedGrab TODO
|
|
|
|
## 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
|
|
|
|
- [x] Field on `Z2MLightOutputTarget` storage dataclass (+ to/from_dict)
|
|
- [x] Field on Z2M create/update/response schemas
|
|
- [x] Validate referenced `MQTTSource` exists at create/update
|
|
- [x] Thread through `output_target_store.create_z2m_light_target` + update
|
|
- [x] Thread through `ProcessorManager.add_z2m_light_target`
|
|
- [x] Thread through `Z2MLightTargetProcessor` constructor
|
|
|
|
### Phase 2 — Z2M processor uses `MQTTManager`
|
|
|
|
- [x] Replace `_mqtt_service` with `_mqtt_runtime` acquired from manager
|
|
- [x] `start()` acquire / `stop()` release
|
|
- [x] `_publish_payload` → `self._mqtt_runtime.publish(...)`
|
|
- [x] `turn_off_lights` borrow-pattern via manager (mirror HA-light)
|
|
- [x] Add `mqtt_manager` to `ProcessorDependencies` / `TargetContext`
|
|
|
|
### Phase 3 — Z2M editor UI
|
|
|
|
- [x] Add MQTT broker `EntitySelect` in Routing
|
|
- [x] Reuse `mqttSourcesCache`
|
|
- [x] Wire `mqtt_source_id` into edit-load + save payload + validation
|
|
|
|
### Phase 4 — DIY MQTT device (`MQTTLEDClient`)
|
|
|
|
- [x] `mqtt_source_id` field on `Device` storage
|
|
- [x] Field on `device_config.MQTTConfig`
|
|
- [x] `MQTTLEDClient` acquires runtime in `connect()`, releases in `close()`
|
|
- [x] Provider threads `mqtt_manager` via `ProviderDeps`
|
|
- [ ] Device editor: MQTT source picker shown for `device_type=mqtt` *(UI still
|
|
pending — backend accepts the field, but the device-create form doesn't
|
|
expose it yet)*
|
|
|
|
### Phase 5 — `AutomationEngine`
|
|
|
|
- [x] Drop `mqtt_service` ctor parameter
|
|
- [x] Drop legacy fallback in `_evaluate_mqtt` (rule must reference a source)
|
|
|
|
### Phase 6 — `api/routes/system.py`
|
|
|
|
- [x] 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`/`connected` pair — surface in UI)
|
|
|
|
### Phase 7 — Startup migration
|
|
|
|
- [x] Seed a "Default Broker" `MQTTSource` if legacy YAML / env had a
|
|
broker configured and the store is empty (`core.mqtt.legacy_migration`)
|
|
- [x] Deprecation warning logged on migration; YAML/env no longer read after
|
|
|
|
### Phase 8 — Remove legacy
|
|
|
|
- [x] Delete `core/mqtt/mqtt_service.py`
|
|
- [x] Delete `set_mqtt_service` / `get_mqtt_service` (mqtt_client.py)
|
|
- [x] Remove `MQTTService` from `main.py`
|
|
- [x] Remove `MQTTConfig` + `resolve_mqtt_password` from `config.py`
|
|
- [x] Remove `mqtt: MQTTConfig` from `Config` (with `extra="ignore"` so legacy
|
|
YAML still loads)
|
|
|
|
### Phase 9 — Verification
|
|
|
|
- [x] `pytest tests/ --no-cov -q` clean (973 passing; removed obsolete
|
|
`test_default_mqtt_disabled`)
|
|
- [x] `ruff check src/` clean
|
|
- [x] `tsc --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`
|
|
|
|
- [x] Added `_registry` + `_type_key` ClassVars + `__init_subclass__(*, type_key)`
|
|
- [x] Rewrote `OutputTarget.from_dict` to dispatch via registry
|
|
- [x] Declared `type_key="led"` / `"ha_light"` / `"z2m_light"` on the three subclasses
|
|
|
|
### Phase 2 — Typed `create_*_target` methods
|
|
|
|
- [x] Extracted `_resolve_brightness`, `_resolve_transition`, `_check_unique_name`,
|
|
`_new_id_and_now`, `_finalize` helpers on the store
|
|
- [x] Added `create_wled_target` / `create_ha_light_target` / `create_z2m_light_target`
|
|
with per-type defaults (transition 0.5/0.3, update_rate 2.0/5.0) baked into
|
|
their signatures
|
|
|
|
### Phase 3 — Typed `update_*_target` methods
|
|
|
|
- [x] Added `update_wled_target` / `update_ha_light_target` / `update_z2m_light_target`
|
|
with `_begin_update` / `_commit_update` helpers
|
|
- [x] Each typed update method validates the target's class before mutating
|
|
|
|
### Phase 4 — Route migration
|
|
|
|
- [x] `create_target` route uses `match data:` to call typed store methods —
|
|
no more `getattr(data, "x", default)` pyramid
|
|
- [x] `update_target` route uses `match data:` and computes `settings_changed` /
|
|
`css_changed` / `brightness_changed` per-arm from typed fields
|
|
- [x] Helpers `_build_ha_mappings`, `_build_z2m_mappings`,
|
|
`_validate_device_exists`, `_resolve_effective_color_vs_id` extracted
|
|
|
|
### 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
|
|
|
|
- [x] `ruff check` clean on all modified files
|
|
- [x] `py -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
|
|
|
|
- [x] Refactor `icon-picker.ts` — replace hardcoded 2-entry `_adapters`
|
|
record with a `Map<EntityType, EntityTypeAdapter>` and expose
|
|
`registerIconEntityType()` for feature modules to register their
|
|
own. Added `makeSimpleIconAdapter()` helper that reduces a
|
|
registration to ~6 lines.
|
|
- [x] Generalised `bodyExtras` for discriminated routes (output-targets
|
|
`target_type` etc.) — now keyed off id, adapter does its own
|
|
lookup.
|
|
- [x] `_onDocumentClick` accepts any registered type instead of
|
|
hardcoded device/target check.
|
|
- [x] 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**.
|
|
|
|
- [x] Integrations (6): weather_sources, value_sources, mqtt_source,
|
|
home_assistant_source, sync_clocks, game_integration
|
|
- [x] 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)
|
|
- [x] Other (3): automation, scene_preset, asset
|
|
|
|
### Frontend (per feature module)
|
|
|
|
For each card render call:
|
|
|
|
- Use the new `core/card-icon.ts` helper:
|
|
`...makeCardIconFields('<type>', entity.id, entity)` spread into the
|
|
mod-card head — computes `iconHtml`/`iconColor`/`iconAttrs` in one go.
|
|
- Register the entity type in the feature module via
|
|
`registerIconEntityType('<type>', makeSimpleIconAdapter({ … }))`.
|
|
|
|
Modules wired:
|
|
|
|
- [x] streams.ts (7 cards: picture, capture, pp, cspt, audio source,
|
|
audio template, gradient — built-in gradients skip the plate)
|
|
- [x] automations.ts
|
|
- [x] scene-presets.ts
|
|
- [x] sync-clocks.ts
|
|
- [x] weather-sources.ts
|
|
- [x] value-sources.ts (bodyExtras propagates `source_type`)
|
|
- [x] mqtt-sources.ts
|
|
- [x] home-assistant-sources.ts
|
|
- [x] game-integration.ts
|
|
- [x] audio-processing-templates.ts
|
|
- [x] assets.ts
|
|
- [x] 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
|
|
|
|
- [x] `cd server && ruff check src/ tests/` clean
|
|
- [x] `cd server && npx tsc --noEmit` clean
|
|
- [x] `cd server && npm run build` produces 2.6 MB bundle
|
|
- [x] `cd 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
|
|
|
|
- [x] `core/devices/discovery_watcher.py` — long-running mDNS browser
|
|
(`AsyncServiceBrowser` kept alive for the process lifetime) + 10 s serial-port
|
|
poller. Fires `device_discovered`/`device_lost` via `processor_manager.fire_event`,
|
|
suppresses events for URLs already in `device_store`. Seeded ports do NOT generate
|
|
startup-time toasts.
|
|
- [x] Wired into `lifespan` (`main.py`). Gated by `notification_preferences.
|
|
background_discovery_enabled`. Default True. Stops before health monitor stop.
|
|
- [x] `api/schemas/preferences.py` — `NotificationPreferences` Pydantic v2 model with
|
|
the 4-event channel matrix, `background_discovery_enabled`, `startup_grace_sec`
|
|
(0..300), `flap_debounce_sec` (0..60).
|
|
- [x] `api/routes/preferences.py` — `GET/PUT /api/v1/preferences/notifications`,
|
|
persisted under `db.set_setting("notification_preferences", …)`. Corrupt stored
|
|
values fall back to defaults instead of 500.
|
|
- [x] Reuses existing `device_health_changed` event from `device_health.py` (already
|
|
fires online/offline transitions on the same event bus).
|
|
- [x] Tests: 7 in `tests/test_preferences_notifications_api.py`, 6 in
|
|
`tests/test_discovery_watcher.py`. Full pytest suite still 899 passing.
|
|
|
|
### Frontend
|
|
|
|
- [x] `js/features/notifications-watcher.ts` — listens to the three `server:*` DOM
|
|
events. Applies user prefs. Pipeline: startup grace → flap debounce → bulk
|
|
coalesce (≥3 events / 800 ms collapse to one summary).
|
|
- [x] Web Notification permission requested from the Settings → Notifications panel
|
|
via a user-gesture button. State chip reflects granted/denied/default.
|
|
- [x] Settings panel — new "Notifications" subtab between Backup and Appearance.
|
|
4 IconSelects (`none`/`snack`/`os`/`both`) + background-discovery toggle +
|
|
permission row + Test-notification button.
|
|
- [x] i18n: `settings.notifications.*` and `notifications.*` keys in en/ru/zh.
|
|
|
|
### Verification (notifications)
|
|
|
|
- [x] `npx tsc --noEmit` clean, `npm run build` produces 2.5 MB bundle.
|
|
- [x] `ruff check src/ tests/` clean. 899/899 pytest pass.
|
|
- [x] 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_listener` feedback loop)
|
|
- Notification history panel — could land later as the reserved `alerts` dashboard cell
|
|
|
|
## Server shutdown action
|
|
|
|
Let user choose what happens to LED targets on server shutdown.
|
|
|
|
- [x] Backend storage: `shutdown_action` in `db.settings` (`"stop_targets"` default | `"nothing"`)
|
|
- [x] Backend route: `GET/PUT /api/v1/system/shutdown-action` in `system_settings.py`
|
|
- [x] Backend schema: `ShutdownActionResponse/Request` in `schemas/system.py`
|
|
- [x] Backend wiring: lifespan shutdown in `main.py` reads action, passes `restore_devices` flag to `processor_manager.stop_all()`
|
|
- [x] `processor_manager.stop_all(restore_devices: bool = True)` — when False, calls public `proc.cancel_task()` (defined on `TargetProcessor`) which awaits cancellation without restoring device state; skips `_restore_device_idle_state` loop. No reach into private `_task` attribute.
|
|
- [x] Frontend: hidden `<select>` + IconSelect in `settings.html` General tab (icons via `ICON_SQUARE` / `ICON_CIRCLE` from `core/icons.ts`)
|
|
- [x] Frontend: load/save handlers in `features/settings.ts`, wired into `openSettingsModal()`
|
|
- [x] 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](server/docs/ui-redesign-mockup.html).
|
|
Phases are independent and CSS-only where possible — backend untouched.
|
|
|
|
### Phase 1 — Design tokens & font embed
|
|
|
|
- [x] 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 via `unicode-range` so only latin paints on first load.
|
|
- [x] `fonts.css` — declare `@font-face` entries for all new families with
|
|
proper `unicode-range` subsetting; keep DM Sans + Orbitron registered
|
|
for legacy-token callers during migration.
|
|
- [x] `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)
|
|
|
|
- [x] `index.html` — `.tab-bar` moved out of `<header>` into a new
|
|
`<aside class="sidebar">`; wrapped content in `.app-body` 2-col grid
|
|
(sidebar | main). `.transport-center` section added between
|
|
`.header-title` and `.header-toolbar` with a placeholder `.transport-status`
|
|
chip ("Ready" → "Armed · N live" wired in Phase 3). All tab-button IDs,
|
|
`data-tab` attributes, and `onclick="switchTab(…)"` handlers preserved.
|
|
- [x] `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/::after` render
|
|
the glowing LED brand mark; `#server-status` repositioned as the LED
|
|
core pip. `#server-version` restyled as a mono-type console badge.
|
|
- [x] `sidebar.css` (new) — vertical channel-strip navigation. Active tab
|
|
gets a glowing left stripe + radial tint. `.sidebar-foot` contains
|
|
a `.cpu-meter` plate 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 via `display: contents` so `.tab-bar`
|
|
falls through to `mobile.css`'s fixed-bottom strip unchanged.
|
|
- [x] `all.css` — new sidebar import after layout.
|
|
- [x] `base.css` — body font-family switched to `var(--font-body)` which
|
|
resolves to Manrope (with DM Sans + system fallbacks). Added
|
|
`font-feature-settings` for stylistic set + alternate 1.
|
|
- [x] Locale additions: `sidebar.workspaces`, `sidebar.load`, `sidebar.fps`,
|
|
`transport.status.ready`, `transport.status.armed` in en/ru/zh.
|
|
- [x] Tutorial + auth selectors (`header .header-title`, `#tab-btn-*`,
|
|
`.tab-bar` querySelector, `a.header-link[href="/docs"]`, onclick
|
|
markers on theme/settings/search) all survive the move.
|
|
- [ ] JS: bind `.cpu-meter` + `.transport-status` chip to existing
|
|
`performance` WebSocket / 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
|
|
|
|
- [x] `cards.css` — `.card` gets rack-module treatment: channel stripe on
|
|
left edge (color-coded via `data-card-type` + `.ch-*` utility classes),
|
|
`::after` corner bracket in top-right, mono-typed metric labels
|
|
planned for Phase 4. Running cards glow the stripe brighter + emit a
|
|
`signalFlow` keyframe strip along the bottom edge.
|
|
- [x] Removed the `@property --border-angle` rotating 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.
|
|
- [x] `dashboard.css` — `.dashboard-target` rows 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.
|
|
- [x] Skeleton-card rewritten: left hairline + corner bracket so it reads
|
|
as "loading module" instead of a generic flashing block.
|
|
`skeletonShimmer` gradient replaces the old opacity-pulse on
|
|
`--text-color`.
|
|
- [x] `_updateSidebarMeter` binds CPU% (Load) and app-CPU share (FPS)
|
|
to the sidebar meter plate on every perf poll.
|
|
- [x] `_updateTransportStatus` updates the transport chip ("Ready" →
|
|
"Armed · N live") whenever the dashboard's running-target set is
|
|
recomputed.
|
|
- [ ] `.hero` 4-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
|
|
|
|
- [x] `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.
|
|
- [x] `.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.
|
|
- [x] `.stream-tab-btn` sub-tabs — mono uppercase with wide tracking,
|
|
active tab shows channel-green underline + glowing count badge.
|
|
- [x] `.perf-chart-card` — channel stripe on the left (replaces old
|
|
`border-top` accent). Per-metric accents swapped to channel palette
|
|
(`--ch-coral` for CPU, `--ch-violet` for RAM, `--ch-signal` for GPU,
|
|
`--ch-amber` for temp). Corner bracket added. Metric values pick up
|
|
`tabular-nums` + a soft glow.
|
|
- [x] `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.
|
|
- [x] Graph editor — toolbar gets a gradient background + hairline +
|
|
rack shadow + backdrop blur. Canvas and nodes untouched.
|
|
- [x] `.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 `.card` and `.dashboard-target`.
|
|
- [x] `cards.css` — channel mapping extended to `.template-card`.
|
|
Direct attr hooks for `data-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
|
|
generic `data-id` (HA / MQTT / weather / value → cyan;
|
|
game-integrations → amber; sync-clocks → violet; HA-light-targets
|
|
→ signal). No JS changes — uses the section markup `CardSection`
|
|
already emits.
|
|
- [x] Graph editor nodes — body fill `--lux-bg-1` with hairline stroke,
|
|
hover bold-line, selected/running stroke `--ch-signal` with
|
|
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-color`
|
|
to channel palette (signal → cyan → signal).
|
|
|
|
### Phase 5 — Modal restyle
|
|
|
|
- [x] `modal.css` — backdrop gains a radial dim + 6 px blur for stronger
|
|
separation. `.modal-content` gets 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-header` gains a vertical channel-color stripe to
|
|
the left of the title; `.modal-footer` picks up a hairline divider.
|
|
- [x] 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
|
|
- [x] `components.css` — inputs use hairline borders, tabular-nums mono
|
|
for `input[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
|
|
|
|
- [x] `mobile.css` (existing file, not forked) — fixed-bottom `.tab-bar`
|
|
promoted 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.
|
|
- [x] 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 in `mobile.css`
|
|
since the cascade was already organized by viewport. A rename adds
|
|
churn without improving maintainability.
|
|
|
|
### Phase 7 — Microcopy + retire legacy
|
|
|
|
- [x] 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.
|
|
- [x] 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-anim`
|
|
is initialized from localStorage with `off` fallback.
|
|
- [DEFERRED] Delete DM Sans + legacy color tokens — would cascade through
|
|
every file that reads `--primary-color` / `--text-color` etc. 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.
|
|
|
|
- [x] `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 from
|
|
`dashboard_collapsed`, `perfMetricsMode`, `perfChartColor_*`.
|
|
- [x] `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 numeric `version`. 6 pytest tests in
|
|
`tests/test_preferences_api.py` cover round-trip, default-empty,
|
|
validation, delete, and unknown-field passthrough.
|
|
- [x] `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.
|
|
- [x] `js/features/perf-charts.ts` — `renderPerfSection()` iterates
|
|
`getOrderedPerfCells()`; existing legacy `setPerfMode` writes
|
|
through to the layout so the global toggle and the customize
|
|
panel stay in sync.
|
|
- [x] `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.
|
|
- [x] i18n keys for `dashboard.customize.*` in en/ru/zh.
|
|
- [ ] (v1.1) Audio meters section — peak / RMS / BPM bars per audio
|
|
source. Schema key `audio-meters` already 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.
|
|
|
|
- [x] Add `bleak>=0.22` as optional extra `[ble]` in `server/pyproject.toml` (desktop-only, NOT in android `build.gradle.kts`)
|
|
- [x] `core/devices/ble_transport.py` — bleak wrapper: scan, connect, write-with/without-response
|
|
- [x] `core/devices/ble_protocols/` package
|
|
- [x] `__init__.py` — `BLEProtocol` dataclass + registry (family → encoder)
|
|
- [x] `sp110e.py` — SP110E / SP108E (service FFE0, char FFE1, `RR GG BB 00 1E` static-color frame)
|
|
- [x] `triones.py` — Triones / HappyLighting / LEDnet (service FFE5, char FFE9, `7E 07 05 03 RR GG BB 10 EF`)
|
|
- [x] `zengge.py` — Zengge / iLightsIn (service FFE0, framing `56 RR GG BB 00 F0 AA`)
|
|
- [x] `govee.py` — Govee unencrypted framed protocol (AES keyed variants — marked experimental)
|
|
- [x] `core/devices/ble_client.py` — unified `BLEClient(LEDClient)` — picks protocol by `ble_family`, averages strip → one color, drops duplicate frames, rate-limits to BLE connection interval
|
|
- [x] `core/devices/ble_provider.py` — `BLEDeviceProvider` + discovery via `BleakScanner`
|
|
- [x] Register in `core/devices/led_client.py::_register_builtin_providers` (guarded `try/except ImportError`)
|
|
- [x] Storage: `ble_family`, `ble_govee_key` fields threaded through `Device.__init__`/`to_dict`/`from_dict`/`_UPDATABLE_FIELDS`/`create_device`
|
|
- [x] Schemas: BLE fields on `DeviceCreate`, `DeviceUpdate`, `DeviceResponse`
|
|
- [x] Routes: BLE fields propagated through create/update in `api/routes/devices.py` + `_device_to_response`
|
|
- [x] ProcessorManager: `ble_family`/`ble_govee_key` added to `_DEVICE_FIELD_DEFAULTS` and `DeviceInfo`; passed through `wled_target_processor.py` and `group_client.py` to `create_led_client`
|
|
- [x] Tests: 21 protocol encoder unit tests + 16 BLEClient fake-transport tests — all passing, 814 total tests still green
|
|
- [x] Frontend: BLE option in the device type picker with a bluetooth Lucide icon; add-device modal shows a 4-option `IconSelect` for 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 to `ble://<address>` pattern; submit payload carries `ble_family` (+ optional `ble_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=ble` endpoint
|
|
- [x] Frontend: `isBleDevice` helper in `core/api.ts`; `ICON_BLUETOOTH` + `ICON_LIGHTBULB` constants in `core/icons.ts`; `bluetooth` path in `core/icon-paths.ts`; i18n keys in `en.json` / `ru.json` / `zh.json`; TypeScript compiles; esbuild bundle rebuilt
|
|
- [x] Android BLE via Kotlin bridge — `BleBridge.kt` singleton (scan/connect/write/disconnect); `android_ble_transport.py` Python wrapper; `make_transport()` factory in `ble_transport.py` auto-selects backend; `BleBridge.init()` called from `LedGrabApp.onCreate`; BLE permissions in `AndroidManifest.xml`
|
|
- [x] Govee per-model AES key — `_encrypt_govee_frame()` in `ble_client.py` uses AES-128-ECB from `cryptography`; key validated on `BLEClient` construction; applied to both `send_pixels` and `set_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:
|
|
|
|
- [x] Rebuild `pydantic-core` wheels for all three ABIs with the current SOABI + libpython linking settings (`android/build-scripts/build-pydantic-core.sh` — now supports `arm64`, `x86_64`, `x86` args; defaults to all three).
|
|
- [x] Verify wheels: all three now list `libpython3.11.so` in `NEEDED` (`llvm-readelf -d`), automated in the build script.
|
|
- [x] Restored `abiFilters += listOf("arm64-v8a", "x86_64", "x86")` in `build.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.
|
|
|
|
- [x] Generate Gradle wrapper (`gradlew`) and commit it
|
|
- [x] 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 -s` on Linux CI
|
|
- `./gradlew assembleDebug` on master push, `assembleRelease` on `v*` tags (if signing secrets set)
|
|
- Uploads APK as CI artifact; attaches to Gitea release on tag push
|
|
- [x] Commit pre-built pydantic-core wheels to `android/wheels/` (arm64, x86, x86_64)
|
|
- [x] 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_PASSWORD`
|
|
- `ANDROID_KEY_ALIAS`
|
|
- `ANDROID_KEY_PASSWORD`
|
|
- [ ] Add `LedGrab-{tag}-android-release.apk` row to the release description table in `.gitea/workflows/release.yml` → `create-release` job
|
|
- [ ] 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.
|
|
|
|
- [x] Root detection — `Root.kt` checks common `su` binary paths and, on demand, runs `su -c id` to actually prove UID 0. First call triggers Magisk's grant dialog; grant is cached per session. Exposed to Python via Chaquopy.
|
|
- [x] `RootScreenrecord.kt` — spawns `su -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 via `PythonBridge.pushRootFrame`.
|
|
- [x] Python-side `RootScreenrecordEngine` (`core/capture_engines/root_screenrecord_engine.py`) mirrors `MediaProjectionEngine` with `ENGINE_PRIORITY=110` (> MediaProjection's 100) so the factory picks it automatically when available.
|
|
- [x] `MainActivity` tries `Root.requestGrant()` before launching the MediaProjection consent flow — on rooted devices the consent dialog is skipped entirely. `CaptureService` has a `createRootIntent()` entry point that bypasses the MediaProjection path.
|
|
- [x] Fallback: if `Root.requestGrant()` returns false (no root, user denied, or `su` timeout) 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 `su` process 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 screencap` fallback — 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.
|
|
|
|
- [x] Manifest: declare `RECEIVE_BOOT_COMPLETED`, `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`, `WAKE_LOCK`; register `.BootReceiver` for `BOOT_COMPLETED` / `LOCKED_BOOT_COMPLETED` / `MY_PACKAGE_REPLACED`.
|
|
- [x] `BootReceiver.kt` — gated by `AutostartPrefs` + `Root.looksRooted()`; dispatches `CaptureService.createRootIntent()` via `ContextCompat.startForegroundService`. Unrooted devices are a no-op because MediaProjection consent cannot be bypassed silently.
|
|
- [x] `AutostartPrefs.kt` — thin SharedPreferences wrapper, defaults to enabled. Shown as a CheckBox on the stopped panel; greyed out on unrooted devices.
|
|
- [x] `CaptureService` returns `START_REDELIVER_INTENT` for root-mode intents so the OS can cleanly restart the service after being killed (token-free path). MediaProjection-mode keeps `START_NOT_STICKY` — restart is pointless with a dead consent token.
|
|
- [x] `isRunning` race: moved assignment to after `startForeground` succeeds, resets on exception; `onStartCommand` wraps `startForeground` in try/catch and stops the service cleanly if the FG transition fails.
|
|
- [x] Root-capture watchdog: coroutine on `serviceScope` checks `RootScreenrecord.framesDelivered` every 5s after a 5s grace. Respawns the pipeline (reusing the existing Python bridge) on stall, caps at 3 consecutive restarts before giving up.
|
|
- [x] `RootScreenrecord.framesDelivered` exposed as a property backed by `AtomicInteger` (was `@Volatile var framesDelivered = 0` with non-atomic `+= 1`).
|
|
- [x] `ScreenCapture` accepts `onProjectionStopped` lambda — `MediaProjection.Callback.onStop` now tears the whole service down instead of leaving a stale FG notification.
|
|
- [x] `MainActivity` wires the autostart toggle to `AutostartPrefs`; enabling it prompts `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` so Doze doesn't kill the FG service on phones.
|
|
- [x] `versionCode` derived from `git rev-list --count HEAD` (or `ANDROID_VERSION_CODE` env 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 `screenrecord` is externally killed, (4) sideload upgrade installs cleanly after the versionCode bump.
|
|
- [ ] Optional follow-up: "kiosk" mode — add `<category android:name="android.intent.category.HOME" />` to `MainActivity` so 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.
|
|
|
|
- [x] Added `com.github.mik3y:usb-serial-for-android:3.8.1` (via JitPack) to `android/app/build.gradle.kts`.
|
|
- [x] Kotlin `UsbSerialBridge` singleton (`android/app/src/main/java/com/ledgrab/android/UsbSerialBridge.kt`) — exposes `listDevices()`, `open(vid, pid, serial, baud)`, `write(handle, ByteArray)`, `close(handle)`. Permission request fires automatically from `open()` when the user hasn't granted access yet. Handles are opaque integers, port map is synchronized, so Python threads can share one bridge.
|
|
- [x] Python `AndroidSerialTransport` in `server/src/ledgrab/core/devices/android_serial_transport.py` drives the bridge through Chaquopy. `SerialTransport` Protocol + `PySerialTransport` + `list_serial_ports()` factory live in `serial_transport.py`; `AdalightClient` and `SerialDeviceProvider` now go through the abstraction instead of importing `pyserial` directly.
|
|
- [x] URL scheme extended: `usb:VID:PID[:serial][@baud]` on Android alongside the existing `COM3[:baud]` / `/dev/ttyUSB0[:baud]` desktop paths.
|
|
- [x] App initializes the bridge on startup (`LedGrabApp.onCreate` → `UsbSerialBridge.init(this)`); manifest declares `uses-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".
|
|
- [x] ESP-NOW client (`espnow_client.py` / `espnow_provider.py`) now routes through `SerialTransport` — `open_transport()` for the gateway serial link, `list_serial_ports()` + `port_exists()` for discovery/validation. Works transparently with `usb:VID:PID` URLs on Android. (Gateway protocol is write-only, so no `read()` extension was needed after all.)
|
|
|
|
## Performance Metrics Abstraction
|
|
|
|
- [x] `MetricsProvider` protocol + dataclass DTOs (`MemorySnapshot`, `ProcessSnapshot`) live in `server/src/ledgrab/utils/metrics/types.py`. Each provider has its own module: `psutil_provider.py`, `null_provider.py`, `android_provider.py`.
|
|
- [x] Factory `get_metrics_provider()` in `utils/metrics/__init__.py` selects Android → psutil → Null. `psutil` import is now confined to one place.
|
|
- [x] `api/routes/system.py` and `core/processing/metrics_history.py` use the provider; no more `if psutil is not None` guards in the hot paths.
|
|
- [x] 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 `/proc` file 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:
|
|
|
|
- [x] Device battery + thermal-zone readings (`/sys/class/power_supply/battery/{capacity,temp}`, `/sys/class/thermal/thermal_zone*/temp` filtered by zone type). Surfaced through `MetricsProvider.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 than `VmRSS` for split-app-process accounting)
|
|
- [WONTDO] Optional: GPU usage via `/sys/class/kgsl/kgsl-3d0/gpubusy` on 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.
|
|
|
|
- [x] Phase 1 — `DeviceConfig` hierarchy + `Device.to_config()` (non-breaking, additive only)
|
|
- [x] Phases 2+3 — narrow `LEDDeviceProvider.create_client` to typed configs; migrate 3 call sites; delete `DeviceInfo` + `_get_device_info` + `_DEVICE_FIELD_DEFAULTS` (single PR)
|
|
- [x] Phase 4 — migrate `tests/test_group_device.py` to `GroupConfig`/`ProviderDeps`; remove legacy `GroupLEDClient` init path; 47-test config suite with 100% coverage on `device_config.py`
|
|
- [ ] Phase 5 (separate PR, optional) — Pydantic v2 discriminated union in `api/schemas/devices.py`; scope frontend POST/PATCH payloads by `device_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).
|
|
|
|
- [x] `YeelightConfig` dataclass with `yeelight_min_interval_ms` rate limit
|
|
- [x] `YeelightClient` in `core/devices/yeelight_client.py` — TCP JSON-RPC,
|
|
averaging single-pixel adapter, client-side rate gate
|
|
- [x] SSDP-style discovery (Yeelight's variant on `239.255.255.250:1982`)
|
|
- [x] `YeelightDeviceProvider` with validate/health/discover
|
|
- [x] Storage + API schemas + route handler wiring
|
|
- [x] 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-design` subagent)
|
|
- [ ] 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.
|
|
|
|
- [ ] Reuse the discovery scaffolding from Yeelight (UDP broadcast pattern)
|
|
- [ ] `WiZConfig` + `WiZLEDClient` + `WiZDeviceProvider`
|
|
- [ ] Frontend additions + locales
|
|
|
|
### 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.
|
|
|
|
- [ ] Generic `NetworkDiscoveryService` that fan-outs mDNS + SSDP + UDP-broadcast probes in parallel
|
|
- [ ] Unified "scan network for devices" UI affordance instead of per-type buttons
|
|
- [ ] Reusable "pair device" component (consent button, countdown, retry)
|
|
|
|
### 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
|
|
|
|
- [ ] Govee LAN API (2023+)
|
|
- [ ] Twinkly
|
|
- [ ] LIFX LAN
|
|
- [ ] Nanoleaf OpenAPI
|
|
- [ ] Mi-Light / MiBoxer UDP gateway
|
|
|
|
### Phase 5 — Open pixel protocols (cheap completionism)
|
|
|
|
- [ ] OPC (Open Pixel Control)
|
|
- [ ] 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)
|