Compare commits

...

142 Commits

Author SHA1 Message Date
alexei.dolgolyov 5ef6ac1317 chore: release v0.6.0
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 3m52s
Build Release / build-linux (push) Successful in 5m20s
Build Release / build-docker (push) Successful in 6m20s
Build Release / build-windows (push) Successful in 7m7s
2026-05-01 19:11:15 +03:00
alexei.dolgolyov 0980cf4dde fix(ui): audio-source modal — preserve device on refresh, relocate refresh action
- Move the device refresh button into the label row next to "Audio Device:"
  so it can no longer overflow the Source panel edge; introduces a small
  .label-row-action style alongside .hint-toggle.
- Restore device selection after refresh by matching on (index, loopback)
  value first, with a trimmed name fallback for OS-side reindexing.
- _selectAudioDevice now syncs the EntitySelect trigger so the visible
  label matches the underlying <select> when the modal opens in edit mode.
- Drop unused min-width/overflow on .transport-status.
2026-05-01 19:04:36 +03:00
alexei.dolgolyov fdac26b9d9 feat: daylight tz, camera engine, value stream + modal/UI polish
- daylight: new daylight_settings module + daylight-tz frontend helper; expanded daylight_stream behavior
- camera engine: capture path additions plus new test_camera_engine suite
- value stream: schema + processing updates (~178 lines)
- color strip: drop cycle effect (cycle.py / color-cycle.ts removed), tighten static path
- modal CSS: large refactor (+883), components.css polish (+110)
- templates: settings, css-editor, value-source-editor, test-template, display-picker, image-lightbox
- frontend core: state, modal, icons, graph-nodes, app
- frontend features: displays, streams, streams-capture-templates, value-sources, settings, color-strips/cards
- locales: en/ru/zh
- storage: color_strip, picture_source, value_source loaders touched
- preferences/sync_clocks/picture_sources routes; home_assistant + templates schemas
2026-05-01 18:42:43 +03:00
alexei.dolgolyov 816a27db73 refactor(ui): drop app footer, move author info to About panel
The "Created by Alexei Dolgolyov..." line lived in a global app
footer that took up vertical space on every page. Move the author
+ contact details into the About tab of the global settings modal
(rendered by renderAboutPanel), where they sit next to the version
pill and license. Adds a localized "donation.about_author" key
(en/ru/zh) and matching .about-hero .about-author styles. Removes
the now-unused .app-footer / .footer-content rules.
2026-05-01 10:55:31 +03:00
alexei.dolgolyov 797b806972 feat: LED hot-path perf, tutorials expansion, modal markup polish
Performance (LED hot path, allocation-free per-frame):
- Adalight: dedicated single-worker tx executor (avoids asyncio.to_thread
  overhead), pre-allocated wire buffer + uint8 scratch, header struct
  precomputed. New tests cover header format, buffer reuse, non-contiguous
  input, and brightness scaling.
- DDP: pre-built struct.Struct for the 10-byte header, allocation-free
  send buffer + memoryview emit path. New tests cover RGB/RGBW packets,
  sequence/PUSH semantics, and multi-packet fragmentation.
- Calibration: precomputed Phase 3 skip-LED resampling (floor/ceil indices,
  fractional weights, take/blend scratch buffers) — per-frame work is now
  np.take + in-place blend, no allocations.
- WLED target processor: matching hot-path tightening.

Tutorials:
- Sub-tab switching, breadcrumb header, and prepare/switchSubTab hooks
  so a tour can open/close the dashboard customize panel and resolve
  targets behind sub-tabs.
- New steps for integrations tab, dashboard customize panel (presets,
  global, sections, perf cells), targets, scenes, sync-clocks.
- en/ru/zh locales updated with the new tour strings.

Dashboard layout:
- Structural deep-equal so the "modified" indicator reflects truth after
  a user edits then reverts, instead of a stale flag.

UI polish:
- Mod-card / modal markup pass across ~33 modal templates and the
  tutorial overlay partial.
- appearance.css, modal.css, tutorials.css refresh.

Tooling:
- Add .mcp.json with code-review-graph MCP server config so the graph
  tools are available to the team out of the box.
2026-05-01 03:02:13 +03:00
alexei.dolgolyov 9d4a534ec6 feat(ui): release notes overlay v2 + settings/streams/dashboard polish
Release Notes overlay redesign (scoped via .release-notes-shell)
- Backend exposes release.assets (name/size/download_url) through
  UpdateReleaseInfo so the frontend can render real download links.
- New masthead: eyebrow + display-font title + tag/published/pre-release
  chip strip + close/external action buttons; opts out of layout.css's
  global `header { height: 60px }` and `header::before` accent bar that
  were leaking into the overlay's <header>.
- Markdown body: <code> filenames are wrapped in clickable <a> via fuzzy
  asset match (exact basename, then same-extension token-overlap), with
  per-asset description tooltip and a small download glyph.
- Per-asset description derived from filename pattern (Windows installer
  /portable/msi, Linux tarball/AppImage/deb/rpm, macOS dmg/pkg, Android
  apk/aab, iOS ipa, generic archives) with i18n keys in en/ru/zh.
- Hide checksum / signature side-files (.sha256/.sha512/.sig/.asc/...).

Settings modal & dashboard polish
- ds-section refresh, rail-channel routing, notif matrix updates.
- Dashboard customize panel + per-account layout updates.
- New docs/settings-modal-redesign.html design reference.

Streams / targets / color-strip
- Stream cards rewrite (cards.css, streams.css, streams.ts).
- Composite stream + metrics history adjustments.
- WLED target processor + color-strip pipeline refinements.
- Color-strip WS source streamer touch-ups.

Misc
- Perf charts overhaul; tabular game-integration / HA / MQTT / weather
  source cards; donation/sync-clocks/scene-presets minor polish.
- New i18n keys across en/ru/zh.

Test infrastructure
- conftest pre-creates the test DB so main.py's legacy-data migration
  doesn't shovel the user's production DB into the test temp dir.
- test_preferences_notifications wipes its own setting at the start of
  the defaults test (was relying on isolation it never enforced).

Pre-commit gates: ruff clean, tsc clean, npm run build clean,
pytest 899/899 passing.
2026-04-29 17:14:05 +03:00
alexei.dolgolyov 51eebf21d5 feat(ui): redesign target pipeline as compact strip + chip row
Drops the legacy "Pipeline details" collapsible block on running
LED target cards. Instead:

- Always-visible 4px segmented timing bar (extract / map / smooth /
  send for video, read / fft / render / send for audio) — same
  stage colors as before, scaled by per-segment ms cost
- One chip row beneath it: total ms / frames count / keepalive
  count, using a new .chip--inline variant (display-weighted number
  + tiny mono-caps unit)
- _patchTargetMetrics now writes only the bar's segments and the
  data-tm spans — bar wrapper survives across polls so the
  flex-transition animates smoothly between samples
- _buildLedTimingHTML replaced by _buildLedTimingSegments (no more
  header / total / legend wrappers — those live in the chip row)

Cleanup
- Drop .target-metrics-collapse / -toggle / -animate / -expanded
  CSS — no callers remain
- Drop targets.metrics.pipeline from en/ru/zh locales — toggle
  label is gone
2026-04-27 01:52:24 +03:00
alexei.dolgolyov 9067db2639 feat(ui): align Targets metric cells with dashboard pattern
mod-card.ts
- ModMetricOpts.extra: raw HTML appended after the .v cell — used
  to embed a sparkline canvas inside the FPS metric block
- ModMetricOpts.valueDataAttrs: data-attrs on the .v element so
  live-update patchers can target the value directly

LED target card
- FPS sparkline (mod-metric-spark-canvas) is embedded INSIDE the
  FPS cell as a sibling of .v — was a separate target-fps-row
  block before, which floated under the metrics grid
- Label hardcoded to "FPS" (the i18n value "Target FPS:" was
  meant for the editor field, not the readout)
- Uptime cell gets ICON_CLOCK; Errors cell gets ICON_OK / ICON_WARNING
  based on count — matches dashboard cell decorations
- Drops the leading FPS icon (display-font number is the focal
  element; no icon needed)
- _patchTargetMetrics now emits the dashboard FPS shape:
  current<span.dashboard-fps-target>/target</span>
  <span.dashboard-fps-avg>avg N.N</span> — picks up dashboard.css
  styling for free

HA Light target card
- Same icon treatment (Uptime → clock; HA → ok/warning by
  ha_connected); FPS icon dropped

Grid sizing
- .devices-grid bumped from minmax(300px, 1fr) / gap 20px to
  minmax(min(380px, 100%), 1fr) / gap 14px — matches the
  dashboard's section grid so metric values like "1m 43s" stop
  truncating at the typical desktop width
2026-04-27 01:42:26 +03:00
alexei.dolgolyov 233b463ac3 feat(ui): migrate Targets cards to mod-card system
LED targets and HA Light targets adopt the dashboard's instrument-
readout vocabulary (mod-head, mod-leds, mod-metrics, mod-foot,
mod-patch, mod-btn, kebab menu) — same classes and tokens already
used by Dashboard and device cards.

mod-card.ts
- ModBtnOpts.dataAttrs for arbitrary data-attrs (used by the LED
  preview toggle's data-led-preview-btn binding)
- ModBodyOpts.extraHtml escape-hatch for live-update widgets that
  don't fit the predefined slots (FPS sparkline canvas, entity
  swatch grid, collapsible pipeline metrics)

LED target card (targets.ts)
- Badge "LED · TGT" pairs with device "WLED · OUT"-style badges
- Meta row: device link → protocol badge → fps → pixel count
- LED bezel: 1-3 dots reflecting checking / streaming / online /
  offline / unreachable
- Headline metrics on running cards (FPS / ERR / UPTIME) preserve
  data-tm selectors so _patchTargetMetrics still patches in place
- Chips for CSS source link, brightness/value-source, threshold
- Patch indicator: STREAMING / UNREACHABLE / STANDBY / OFFLINE /
  CHECKING
- Foot: START/STOP go/stop variant + LED preview + Edit
- Kebab menu: Duplicate / Hide / Delete (replaces top-right trash)
- FPS sparkline + collapsible pipeline preserved via extraHtml
- Tag chips and LED preview panel appended after wrap (mirrors
  devices.ts pattern)

HA Light target card (ha-light-targets.ts)
- Badge "HA · LIGHT"
- Meta: HA source link → light count → update rate
- LEDs: blink running, fault when ha_connected === false, off idle
- Running metrics: RATE / UPTIME / HA status
- Patch: STREAMING / DISCONNECTED / STANDBY / NOT CONFIGURED
- Buttons keep [data-action] for initHALightTargetDelegation
- Live entity color swatches preserved via extraHtml

Misc
- Chip border-radius dropped from 999px (pill) to var(--lux-r-sm,
  3px) — sharp corners match badges/metrics/buttons elsewhere
- _patchTargetMetrics FPS readout uses <small> for the target
  fraction instead of the legacy target-fps-target span
2026-04-27 01:33:13 +03:00
alexei.dolgolyov de13f44f24 feat(autostart): suppress browser auto-open on Windows login
When the user enables "Start with Windows" in the installer, the app
launches on every PC login. Previously each login popped a fresh WebUI
tab, which is noisy for a tray-resident background service.

The autostart shortcut now passes --autostart to start-hidden.vbs, which
sets LEDGRAB_AUTOSTART=1 in the child env. __main__ checks this flag
alongside LEDGRAB_RESTART when deciding whether to open the browser.

Manual launches (desktop/start-menu shortcuts) and the installer's
post-install "Launch LedGrab" finish-page action are unchanged — they
don't pass the arg, so they still open the WebUI tab.
2026-04-26 23:41:03 +03:00
alexei.dolgolyov 1c9acc5afb feat(api-input): make SegmentPayload start/length optional
start defaults to 0, length defaults to led_count - start (the rest of
the strip from start). A single segment with only mode + color now
fills the entire strip — no more length: 9999 magic value clients have
to pass.

Buffer auto-grow only fires for segments with an explicit length past
the current end; implicit "to the end" segments adapt to the current
strip size.
2026-04-26 23:34:42 +03:00
alexei.dolgolyov a56569b02f feat(ui): cards redesign + settings, modal, toolbar polish
Dashboard cards (mod-card system)
- New mod-card / mod-menu modules backing dashboard cards
- Reworked card colors, sections, dashboard layout, perf charts
- Channel-stripe styling, hairline borders, signal-flow animation
  on running cards, refined metric grid

Multiselect bulk toolbar
- Replaced tri-state checkbox with explicit Select-all / Deselect-all
  icon buttons; both disable when not applicable
- Dim + slight blur on non-selected siblings during selection mode so
  the active picks pop; selected card gains a subtle lift + primary-color
  glow halo
- Bulk tick uses ICON_CHECK from the icon registry (was U+2713) and
  scale-pops in via a cubic-bezier overshoot keyframe
- Toolbar restyled with luxury gradient bg, top accent stripe, glass
  blur, neon hover glows on each button group

Settings modal
- Tab bar converted to icon-only (cog / hard-drive / bell / palette /
  refresh / help) so labels never overflow at any locale; title and
  aria-label preserve translated names. Tabs distribute evenly via
  flex: 1 1 0 + space-around — no overflow possible
- IconSelect auto-populates <option> elements when the underlying select
  is empty, fixing the blank notification triggers (root cause: setting
  .value on an empty select is a no-op)
- Tab activation calls scrollIntoView on the active button as a safety
  net for narrow viewports

Modal exit animation
- Added symmetric fadeOut + slideDown keyframes; .modal.closing applies
  them with animation-fill-mode: forwards
- Modal.forceClose() defers display:none until animationend (with timer
  fallback). State cleanup (focus, body lock, stack) runs immediately so
  callers querying state get correct values
- isOpen returns false during the close animation; open() cancels any
  in-flight close so re-open works during the animation
- prefers-reduced-motion disables all modal animations

Locale picker
- Dropped redundant English/Русский/中文 long-form labels — picker now
  shows only EN / RU / ZH
- IconSelect trigger/cell hides empty icon/label slots via :empty so the
  layout collapses cleanly for minimal items

Filter input (cards section)
- Embedded magnifier icon via data URI (no HTML change); monospace
  uppercase placeholder, lux-bg-0 background, neon focus ring with inset
  shadow + outer glow
- Reset button only shows when the input has content (CSS-only via
  :placeholder-shown sibling selector — JS-resilient)

Snack toast
- Glass background (gradient + backdrop-blur) with top channel-color
  accent stripe matching the modal/toolbar language
- Per-type --toast-ch drives border/glow/timer color (success → primary,
  error → danger, info → info)
- Undo button gets a tinted hover with channel-color halo

Top header toolbar
- Removed hairline border from .header-btn for a flatter look; hover
  keeps the subtle background tint and primary-color glow

Device URL hyperlink
- Styled .mod-meta__link to pick up the card's --ch accent (instead of
  inheriting browser-blue underline). Dotted underline at rest solidifies
  on hover; soft text-shadow glow; web icon dims at rest, brightens on
  hover

Misc
- ICON_CHECK and ICON_HARD_DRIVE added to the icon registry
- Existing card-redesign demos checked in under docs/
- Removed obsolete docs/plans/device-typed-configs.md
2026-04-26 03:10:16 +03:00
alexei.dolgolyov ccf4406349 Merge branch 'feat/device-event-notifications'
Configurable device-event notifications: snackbar + Web Notifications
for online/offline (configured targets) and discovery (new WLED/serial
on the LAN/USB) events, with per-event channel matrix and background
discovery toggle.
2026-04-25 17:49:30 +03:00
alexei.dolgolyov 8aa3a323d6 feat(notifications): device event notifications (snack + Web Notifications)
Surface device connection state changes (configured target online/offline)
and discovery events (new WLED on LAN, new serial port, devices that
disappear) through a configurable per-event channel matrix:
none / snack / OS / both.

- Backend: long-running mDNS browser + 10 s serial poller in
  core/devices/discovery_watcher.py, gated by user pref. Reuses the
  existing device_health_changed event for online/offline transitions.
  New GET/PUT /api/v1/preferences/notifications endpoint with Pydantic v2
  schema (channel matrix + background-discovery flag + grace/debounce).
  13 new tests, full suite still 899 passing.
- Frontend: features/notifications-watcher.ts with startup-grace +
  flap-debounce + bulk-coalesce pipeline. Web Notifications API for the
  OS channel (no platform-specific code, works in PWA shell).
  New "Notifications" tab in Settings with 4 IconSelect rows + bg toggle
  + permission row + test button. en/ru/zh translations.

Defaults: device_offline=both (urgent), online/discovered=snack, lost=none,
background discovery on. Already-configured devices are filtered from
discovery events to avoid double-notifications.
2026-04-25 17:49:20 +03:00
alexei.dolgolyov 8e109f32b9 fix(pwa): add mobile-web-app-capable meta tag
Chrome deprecated apple-mobile-web-app-capable in favor of the
standard mobile-web-app-capable. Add the new tag while keeping the
Apple variant for iOS Safari compatibility.
2026-04-25 15:36:59 +03:00
alexei.dolgolyov 033c1f6a92 ci: add workflow_dispatch and skip lint/test on release commits
Release-bump commits don't change code that affects lint/tests, and
release.yml already runs in parallel. Manual dispatch lets us re-run
on demand if needed.
2026-04-25 15:36:51 +03:00
alexei.dolgolyov 0804f54537 chore: release v0.5.0
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 2m16s
Build Release / build-linux (push) Successful in 3m54s
Build Release / build-docker (push) Successful in 7m30s
Build Release / build-windows (push) Successful in 8m37s
Lint & Test / test (push) Successful in 8m45s
2026-04-25 15:21:02 +03:00
alexei.dolgolyov 66f921c07f Merge branch 'feat/lumenworks-ui-redesign'
Lint & Test / test (push) Successful in 2m36s
Lumenworks studio-console redesign + per-account dashboard customization
+ Inputs/Integrations/Graph treatment + transport-bar uptime + server
shutdown action.

Sub-features (in order on the branch):
- feat(ui): Lumenworks tokens, fonts, transport bar, channel-strip sidebar
- feat(ui): dashboard polish, perf strip, transport-bar controls
- feat(dashboard): per-account customizable dashboard with slide-in panel
- feat(ui): item-card restyle, perf hover tooltips, FPS ceiling
- feat(ui): Lumenworks treatment for Inputs / Integrations / Graph tabs
- fix(ui): cards on pure black/white, decoupled from bg-anim
- fix(ui): single-row header + readable sidebar labels at narrow widths
- feat: server shutdown action with public cancel_task lifecycle method
- feat(ui): live card-color picker, monotonic uptime ticker, default
  preset uses base palette
- fix(ui): channel stripe paints only on custom-color or running cards
- chore: harden test isolation, gitignore stale src/data, mark TODO done

Pre-merge audit:
- 886/886 pytest passed twice in a row
- ruff + tsc clean
- frontend bundle rebuilt at static/dist
- python package reinstalled in editable mode (dev WebUI now reports
  0.4.2 instead of stale 0.3.0 dist-info)
2026-04-25 15:12:27 +03:00
alexei.dolgolyov 80f01d4813 chore: harden test isolation, gitignore stale src/data, mark shutdown action done
- ``tests/test_preferences_api.py`` no longer captures the auth API
  key at module-import time. The new ``client`` fixture resolves it
  inside its body and bakes the Bearer header into ``TestClient.headers``,
  so the e2e conftest swapping the global config singleton during
  collection cannot leave the test holding a stale 401-bound header.
  Same proven pattern as ``test_audio_processing_templates_api.py``.
- ``.gitignore`` now anchors ``/server/src/data/`` defensively. If the
  server is launched from ``server/src/`` (uncommon but possible during
  ad-hoc debugging), its relative ``data/`` resolves there. Templates
  now live in SQLite (``capture_templates`` / ``pattern_templates`` /
  ``postprocessing_templates`` tables); any stale ``*.json`` that
  lands in that directory is a runtime export and must not be
  committed.
- Three such stale exports were untracked at the start of the
  pre-merge audit and have been deleted from the working tree.
- ``TODO.md`` flips the shutdown-action checklist to done and notes
  that real-hardware verification (WLED + serial after Ctrl+C) is
  still pending.
2026-04-25 15:11:39 +03:00
alexei.dolgolyov b1ee3c3942 fix(ui): channel stripe paints only on custom-color or running cards
Reported during pre-merge review of the Lumenworks redesign:
non-dashboard cards (Inputs, Integrations, Targets) all showed
aggressive cyan / green left stripes "regardless of custom color set
or not", and even the ``+`` Add card carried a stripe.

Root cause: ``.template-card`` defaulted to ``--ch: cyan`` and
``::before`` painted it unconditionally; ``.add-template-card``
inherits ``.template-card`` so it picked the stripe up too.

Fix: gate the ``::before`` channel stripe behind opt-in selectors.
It now paints only when the card carries ``data-has-color="1"``
(the user picked a personal colour via the picker, set by ``wrapCard``)
or has the ``card-running`` class (the "patched and live" indicator).
Dashboard module rows are unchanged — their ``::before`` already
runs at ``opacity: 0.6`` and was approved as the visual benchmark.

``add-template-card::before`` is hidden unconditionally with
``!important`` since the Add card is not an entity and should never
carry a channel hue.
2026-04-25 15:11:24 +03:00
alexei.dolgolyov e0ff40f4f5 feat(ui): live card-color picker, monotonic uptime ticker tweaks, default preset uses base palette
Three adjacent UI fixes that surfaced while soaking the Lumenworks
redesign:

- ``card-colors.ts`` now writes the user's picked color through to
  *every* card representing the same entity (e.g. the targets-tab card
  AND its dashboard mirror), not just the one that owns the picker.
  Sets the ``--ch`` custom property on each match instead of a literal
  ``border-left``, which avoided the double-stripe (custom border +
  Lumenworks ``::before`` channel stripe) the old approach produced
  and reaches mirrors the picker callback's ``.closest()`` lookup
  couldn't.
- ``appearance.ts`` "default" preset now *clears* its colour overrides
  instead of stamping the historic muted greys (#1a1a1a / #2d2d2d /
  #f5f5f5 / #ffffff). With the redesign's pure-black / pure-white base
  palette in ``base.css``, "default" should mean "use the base" — the
  preset swatch in Appearance now matches what ships out of the box.
  Existing users with "default" selected will see a one-time visual
  shift to the new neutrals on next reload; this is intentional.
- ``dashboard.css`` mod-metric label row gets explicit sizing for the
  small status glyphs (clock / check / warning) so they sit beside the
  mono-caps label without competing with the big value. Errors cell
  picks up the coral channel tint when the count is non-zero.
2026-04-25 15:11:09 +03:00
alexei.dolgolyov 3f80ef2101 feat: server shutdown action with public cancel_task lifecycle method
Lets users choose what happens to LED targets when the server shuts
down. Default ("stop_targets") runs the existing per-device stop
sequence, so devices with auto-restore replay their prior state.
"Nothing" cancels the capture tasks without sending restore frames,
so the LEDs keep displaying their last frame on shutdown.

Backend:
- New setting ``shutdown_action`` persisted in db.settings
  (``stop_targets`` default | ``nothing``) with GET/PUT
  ``/api/v1/system/shutdown-action`` endpoints
- ``ProcessorManager.stop_all(restore_devices: bool = True)`` now
  picks the path based on the flag — ``proc.stop()`` for the normal
  branch, public ``proc.cancel_task()`` for the "nothing" branch.
- ``TargetProcessor.cancel_task()`` (new, on the abstract base) cancels
  the loop task and *awaits* its termination so no half-written frame
  is in flight when the process exits. Replaces an earlier draft that
  reached into the private ``_task`` attribute via ``getattr``.
- Lifespan in ``main.py`` reads the setting at shutdown and forwards
  the flag; falls back to ``stop_targets`` on any read error.
- ``/health`` exposes ``uptime_seconds`` (process-wide monotonic clock
  captured at first import of ``api.routes.system``) so the WebUI can
  show the *server's* uptime instead of the browser session's.

Browser launch:
- ``__main__._open_browser`` now polls ``/health`` for up to 30 s
  instead of sleeping a flat 2 s, so the tab opens once the server
  actually accepts requests.

Frontend:
- New "Shutdown action" picker in Settings → General, rendered via
  IconSelect with ICON_SQUARE / ICON_CIRCLE (added to ``core/icons.ts``
  + ``circle`` path to ``icon-paths.ts``).
- Transport-bar uptime ticker reads ``window.__serverUptime`` (typed
  in ``global.d.ts``); shows "—" until the first /health response
  lands so refresh doesn't briefly flash 00:00:00. After 99 h the
  format widens to "Dd HH:MM:SS".
- New i18n keys for the action picker (label, hint, opt.stop /
  opt.nothing + descriptions, saved / save_error toasts) in en/ru/zh.

No data migration needed — the setting is additive and defaults to
the existing behavior.
2026-04-25 15:10:48 +03:00
alexei.dolgolyov 2bae304107 fix(ui): single-row header + readable sidebar labels at narrow widths
At ≤1100px the header grid only declared 3 tracks for 4 children, so
the toolbar wrapped to a second row, doubling the header height. Add a
4th track, tighten the meta cluster, and hide non-essential toolbar
items (API link, tour-restart) so everything fits in one row. At
≤900px drop CPU/Mem cells (Uptime + Poll remain) so the toolbar still
fits beside the meta cluster.

Sidebar tab captions on the 56 px icon rail were ellipsis-truncated to
"DASHBO…" / "AUTOMA…" / "INTEGR…". Switch to a 2-line clamp with
tighter font/tracking so each label renders in full.
2026-04-25 13:54:18 +03:00
alexei.dolgolyov dd415e2813 fix(ui): cards on pure black/white, decoupled from bg-anim
Three related fixes after the Phase-4 migration landed:

- `--card-bg` flipped from `#101216` / `#f5f6f8` to pure `#000000` /
  `#ffffff` in base.css. Off-pure greys read as muddy when sitting on a
  pure-black/white page background; pure values keep card surfaces flush
  with the rest of the chrome and let the channel stripe + corner
  bracket carry all the visual differentiation.

- Removed the `[data-bg-anim="on"] .card { background: rgba(...) }`
  block that turned every entity card translucent whenever the WebGL
  background was enabled. Card backgrounds are now stable across the
  toggle — the shader bleeds through `body { background: transparent }`
  only, not through cards. The same card now reads identically with the
  shader on or off.

- WebGL shader base colour (`_bgColor` in bg-anim.ts and bg-shaders.ts)
  was using the legacy mid-grey `#1a1a1a` / `#f5f5f5`. That added a
  constant grey haze under the additive accent glow that didn't exist
  on the surrounding pure-black/white page. Switched to `[0,0,0]` /
  `[1,1,1]` so the shader composes against the same base as the page.

- Reverted two leftovers from the Phase-4 commit where I had migrated
  `.template-card` and `.graph-node-body` away from `var(--card-bg)`
  toward `var(--lux-bg-1, …)`. Those backgrounds now live on
  `var(--card-bg)` again, matching every other migrated card.
2026-04-25 02:42:57 +03:00
alexei.dolgolyov b43e1cf375 feat(ui): Lumenworks treatment for Inputs / Integrations / Graph tabs
Brings the remaining tabs in line with the Channels-tab visual language:

- .template-card now mirrors .card and .dashboard-target — channel stripe
  on the left edge with glow, silkscreened corner bracket top-right,
  hairline border on --lux-bg-1, hover lift + stripe widen-and-glow.
  Covers streams, capture / pp / cspt / pattern / audio templates and
  every Integrations card (HA / MQTT / weather / value / sync clocks /
  game integrations).

- Channel mapping extended in cards.css. Direct attribute hooks for the
  per-domain ids; section-scoped hooks via [data-card-section="…"] for
  the 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.render already emits.

- Graph editor nodes pick up the studio-console palette: --lux-bg-1
  fill with hairline stroke, hover bold-line, selected/running stroke
  --ch-signal with drop-shadow glow. Title font moved off Big Shoulders
  Display (which read as "stretched" at 12 px) onto --font-body
  (Manrope); subtitle keeps the mono-uppercase caption treatment with a
  conservative letter-spacing. Running gradient now rides the channel
  palette (signal → cyan → signal) rather than the legacy primary /
  success colours. Port labels and grid dots adopt --lux-line tokens.

- Graph node titles get real text-overflow:ellipsis behaviour. SVG
  <text> can't do that natively, so renderNodes runs a post-mount fit
  pass that binary-searches the longest character prefix that fits
  inside the clip rect (with 2 px slack), suffixed with "…". Trailing
  whitespace is stripped before the ellipsis so we never get "Foo …".
  Full text is stashed on data-full-text so the fit can be re-run on
  re-renders.

Also bundles two perf-charts fixes from the same session:

- Hover regression — listener was bound to .perf-charts-grid, which
  rerenderPerfGrid() replaces. Moved to document.body with a guard, and
  the cursor → sample math now uses the same sliceN as the spark
  rendering so the tooltip stays accurate when the user changes the
  window setting.

- Color picker on every perf cell. Patches / Total FPS / Devices now
  expose the same color picker as the spark cells; defaults added to
  METRIC_CSS_VARS. Each card gets an inline --perf-accent on render so
  saved colours apply immediately, including across rerenderPerfGrid.
2026-04-25 02:27:38 +03:00
alexei.dolgolyov 56853b7123 feat(dashboard): per-account customizable dashboard with slide-in panel
Open-registry section/perf-cell schema persisted server-side under
db.get_setting('dashboard_layout'); localStorage cache for instant
first-paint, server sync after auth. 5 built-in presets
(Studio/Operator/Showrunner/Diagnostics/TV); JSON export/import.

Slide-in Customize panel toggles section + perf-cell visibility,
reorders via hand-rolled HTML5 drag (with up/down buttons for
keyboard/TV-remote use), changes density per section, and exposes
global Width / Animations / Perf-mode / Window with per-cell Inherit
overrides.

Window setting now drives the actual sparkline slice (30s/1m/2m/5m at
configurable poll interval) instead of always rendering 120 fixed
samples. Perf-grid edits re-render in place — sparklines repaint from
persistent module-level history, value labels replay from cached
last-fetch payload, so there is no flicker frame and no zero-data
window between layout change and next poll. initPerfCharts now fires
an immediate fetch on init so reload no longer shows "—" until the
first interval tick.

Reset confirmation uses the project's themed showConfirm modal
instead of the browser dialog. Reserved registry keys (audio-meters,
alerts, led-preview, source-thumbs, pinned, flow) are forward-
compatible so v1.1 cards slot in without a schema bump.

Backend exposes GET/PUT/DELETE /api/v1/preferences/dashboard-layout
treating the body as opaque JSON with a numeric version gate; covered
by 6 round-trip / validation / unknown-field tests.
2026-04-25 01:43:14 +03:00
alexei.dolgolyov 70c95d1c09 feat(ui): item-card restyle, perf hover tooltips, FPS ceiling
Item cards (Automations, Channels, Inputs, Integrations):
- `.card-title` — bumped to weight 700, -0.01em tracking, solid --lux-ink
  for better presence against the flat card bg.
- `.card-subtitle` / `.card-meta` — mono font, 0.04em tracking, tighter
  gap so rule chips pack in a readable row.
- `.stream-card-prop` rule chips — rectangular 2px radius + hairline
  border + flat dark bg (was rounded 10px grey pill). Channel-signal
  icon tint; hover fades in a channel-green wash with matching border.
- `.badge` generic — rectangular 2px radius, mono 0.62rem, 0.12em
  tracking, hairline border slot for variants.
  - `.badge-automation-active` — channel-signal tinted bg + border +
    soft outer glow so the "ACTIVE" state reads at a glance.
  - `.badge-automation-inactive` / `-disabled` — transparent with a
    hairline outline so they sit quietly alongside the active variant.
- `.device-url-badge` — switched from rounded pill to rectangular
  hairline mono chip; hover shifts to filled bg + bolder border +
  brighter ink.
- `.card-actions` — 1px hairline top divider, 6px gap.
- `.btn-icon` — 7/10px padding, 1rem icon, hairline border, channel-
  signal glow on hover (replaces the old scale(1.1) jiggle).
  - `.btn-icon.btn-warning` — amber ink + hairline + amber hover glow
    (drives the "disable" action in the automation card).
  - `.btn-icon.btn-success` — signal-green ink + hairline + green hover
    glow ("enable" action).

Cross-link navigation highlight:
- `cardHighlight` keyframes were using an undefined `--primary-rgb` var,
  so the outer glow fell back to 59/130/246 (the Tailwind blue default).
  Rewritten with `var(--ch-signal)` + color-mix so the highlight tracks
  the accent picker and reads as signal-green. Added double-layer
  box-shadow (ring + 32px/10px bloom) so the highlight is obvious on
  the flat dark/light card surfaces. Added .dashboard-target to the
  selector + `isolation: isolate` so the glow isn't clipped inside
  overflow: hidden containers (perf strip cells, tree-nav panels).

Perf strip (follow-up polish):
- Total FPS cell shows `/<N>` ceiling suffix next to the live value —
  sum of fps_target across running targets, styled like the Patches
  "/12". A dashed horizontal reference line at that ceiling is rendered
  on the sparkline so the live value reads as "percentage of max
  achievable throughput." Y-axis ceiling grows to targetSum * 1.1 so
  the dashed line never clips.
- Removed the empty `.perf-chart-app` pill in the FPS cell (no app
  variant). Added `:empty { display: none }` as a safety so any other
  unpopulated cell doesn't render a ghost pill.
- Hover tooltips on all sparks — single floating `.perf-chart-tooltip`
  in <body> with fixed positioning; event-delegated from the perf
  grid so re-renders don't need rebinding. Shows metric label + sys
  value + app value (in both-mode) + "−Ns ago" age line derived from
  the poll interval. Vertical marker line follows the cursor over the
  spark; `cursor: crosshair` on the spark container signals interact-
  ability. `pointer-events: none` shifted from the spark container
  down to the inner SVG so hover events land on the container.

Grid:
- Perf strip capped at 4 cols even on widescreen; wraps to 2 rows ×
  4 when the full 7 cells are present. Responsive breakpoints at
  1100 / 760 / 480 px.
- Big value font uses `clamp(1.8rem, 2.8vw, 2.8rem)` so readouts
  like "18.9/31.8 GB" fit a 1fr cell at desktop while still scaling
  down on narrow viewports. `white-space: nowrap; flex-wrap: nowrap;
  overflow: hidden; text-overflow: clip` prevents mid-text wrapping.
- `.perf-chart-spark` uses `margin-top: auto` so sparkline baselines
  align across cells regardless of whether a subtitle is present
  (CPU/GPU model name, FPS min/max).

Dashboard target meta:
- Integrations card stripe reverted to the default signal color so it
  matches the overall accent picker; the health-dot inside the card
  carries the connection state. Removed the per-integration channel
  override in both cards.css and dashboard.css.

Section headers:
- `.dashboard-section-header` / `.subtab-section-header` underline
  switched from dashed to solid; channel-green 40px accent rule on
  the left remains.
- Section count badge (`.dashboard-section-count`) restyled to match
  the rest of the badge family (mono tabular-nums, 2px radius, hairline
  border, --lux-bg-3 fill).

Build: tsc --noEmit clean; CSS bundle stable at ~216 KB.
2026-04-24 21:59:30 +03:00
alexei.dolgolyov e5a2af9821 feat(ui): dashboard polish, richer perf strip, transport-bar controls
Dashboard perf strip:
- Unified rack-module shell with hairline-divided cells (mockup parity)
  replacing 3 separate perf cards. Cells auto-wrap to 2 rows of 4 on
  widescreen; responsive breakpoints at 1100 / 760 / 480 px.
- Active Patches cell (first) shows running/total channel count plus up
  to 4 live FPS readouts with channel-colored stripes; bottom-right
  radial glow anchors the "live channel bank" corner.
- Total FPS cell — aggregate throughput across running targets, mono
  "fps" unit suffix, session-peak-scaled sparkline with a 60 FPS floor.
- Devices cell — online/total count + per-device dot strip (green when
  online with signal-glow, coral when offline, tooltip with name +
  latency), fed from /devices/batch/states (added to the dashboard
  batch poll).
- Value font uses clamp(1.8rem, 2.8vw, 2.8rem) + white-space: nowrap so
  long readouts (RAM "18.9/31.8 GB", GPU "50% · 37°C") scale down
  instead of wrapping.
- Sparklines anchor to the cell bottom via margin-top: auto so baselines
  align across cells regardless of subtitle presence.
- App-load tag ("APP 3.1%") moved to a pinned top-right position per
  card, accent-colored pill; replaces the subdued inline badge.
- Perf mode toggle (System / App / Both) triggers an immediate poll so
  positioning updates without waiting for the next tick.
- Chart.js removed from perf-charts — inline SVG sparklines with
  drop-shadow filter for the "lit instrument" feel. Chart.js still used
  for per-target FPS charts via chart-utils (now owns the registration).
- Fixed history seed bug: app_ram is MB in the server history payload,
  not percent — convert to percent using sample's ram_total before
  pushing into _appHistory.ram. Skip seeding app_gpu_mem since the
  history schema has no gpu_memory_total.
- Temperature card reveals with an explanatory hint when the backend
  reports cpu_temp_hint_key (e.g. Windows without LibreHardwareMonitor)
  instead of silently hiding; .perf-chart-card-hint neutralizes the big
  display font so the message reads as plain body copy.

Transport bar:
- LED brand mark — 28 px, double-layer signal glow (0 22px + 0 8px),
  brandPulse animation. Brand-stack wraps the title + version so
  "LED GRAB" sits above "V0.3.0" on a single line each.
- Transport status chip — bigger (9/18 padding), mono uppercase,
  inner+outer signal glow when .is-armed.
- Transport meta cells — Uptime (JS-local session ticker), CPU (app
  CPU share), Mem (app RAM, G/M format) as stacked KEY/VALUE mono
  readouts with hairline separators.
- New interactive Poll cell cycles through 1/2/5/10s presets on click;
  replaces the range slider that used to live in the Dashboard toolbar
  (it controlled the whole app, not just the Dashboard).
- Header icon buttons — hairline-bordered 30 px squares with channel-
  glow on hover, replacing the pill container.
- Perf poll moved to global bootstrap so transport CPU / Mem stay live
  across all tabs (was paused when leaving the Dashboard).
- Connection pip (#server-status) hidden; the brand mark itself turns
  coral when offline via :has() selector on .header-title.

Dashboard cards:
- renderDashboardTarget now emits full rack-module markup with CH badge,
  name, meta, LED cluster, 3-cell metric grid (FPS / Uptime / Errors),
  and patch-label + stop button. Running cards get the signal-flow
  strip at the bottom. data-fps-text / data-uptime-text / data-errors-
  text hooks preserved so _updateRunningMetrics updates in place.
- LED count surfaced in the target card meta line (e.g. "LED · WLED ·
  144 LED · GRADIENT") when the linked device reports led_count > 0.
- Integrations (HA + MQTT) picked up .mod-head markup — compact module
  layout with online/offline patch indicator. Integration card stripe
  uses the default signal color (not cyan or amber).
- Scene presets, sync clocks, automations gain the same compact module
  treatment. Automations/scenes dropped into a dashboard-autostart-grid
  so they share the visual language.
- Perf mode toggle, stream sub-tabs, cs-count / tree-count /
  tab-badge / dashboard-section-count badges all use the mono
  rectangular style with tabular-nums.

Command palette:
- Flat background (no gradient), channel-accent rule across the top,
  mono placeholder / group headers / footer, active result gets a
  channel-green left stripe.

Modals:
- Popover + backdrop get a stronger radial dim + 6 px blur.
- Per-modal-ID channel lanes (target→green, source→cyan, audio→magenta,
  automation/scene→violet, settings→amber, confirm→coral) via --modal-ch
  override.
- Modal header picks up a vertical channel stripe + hairline divider;
  footer gets hairline top + subtle wash.

Components:
- Inputs use hairline borders + tabular-nums mono for number fields;
  focus state has channel-green ring + soft glow.
- Buttons switch to mono-uppercase with signal-glow on primary,
  coral-glow on danger, hairline border on secondary.
- Card background flattened — removed gradient wash in favor of solid
  --lux-bg-1 for both dark (#0e1014) and light (#f6f8fb).
- Page background: pure black for dark, pure white for light.

Color-picker:
- Always detaches to <body> with fixed positioning when its swatch sits
  inside an overflow: hidden / auto / clip ancestor (perf strip, modal
  bodies, tree-dd panels). Prevents the popover getting clipped.

Settings modal:
- Remembers the last-opened tab via localStorage key
  settings_active_tab; falls back to 'general' if the tab id no longer
  exists. Explicit overrides (donation → about, update badge →
  updates) still work because callers invoke switchSettingsTab after
  openSettingsModal.

Microcopy:
- Sidebar / transport localization for en/ru/zh:
  sidebar.workspaces · transport.meta.{uptime,cpu,mem,poll,poll_hint}
  · transport.status.{ready,armed} · dashboard.perf.{active_patches,
  total_fps,devices}

Backend (coordinated with frontend):
- /system/performance now returns cpu_temp_hint_key when no live CPU
  temperature is available, so the Temperature card can render an
  actionable explainer instead of being hidden. Frontend respects the
  key via t() lookup.

Section headers:
- Underline switched from dashed to solid; channel-green accent rule
  (40 px) on the left remains.

Build / tests:
- ruff clean on touched Python files.
- tsc --noEmit clean.
- Python metrics-provider tests: 18 passed.
- CSS bundle ~214 KB.
2026-04-24 20:28:44 +03:00
alexei.dolgolyov 539e43195f feat(ui): Lumenworks studio-console WebUI redesign
Full-app UI/UX refresh committing to a tech-instrument / studio-console
aesthetic inspired by hardware synths, Eurorack panels, and DAW layouts.

Design tokens and fonts:
- Embed Manrope (body), JetBrains Mono (labels/metrics), Big Shoulders
  Display (numeric readouts) as local .woff2 variable fonts with
  latin + latin-ext + cyrillic + cyrillic-ext subsets via unicode-range.
- New Lumenworks token layer in base.css: --lux-bg-0..3, --lux-line(-bold),
  --lux-ink(-dim/-mute/-faint), --ch-signal/-cyan/-magenta/-amber/-coral/
  -violet channel palette, --lux-signal-glow, --lux-shadow-rack, all
  theme-aware for dark + light. Existing tokens untouched for compat.

Shell (header + sidebar):
- Header rebuilt as a 3-column CSS-grid transport bar (brand | center |
  toolbar) with a glowing LED brand mark rendered via pseudo-elements on
  .header-title. Gradient channel-color rule under the bottom border.
- New sidebar.css introduces a vertical channel-strip nav. Active tab
  gets a glowing left stripe + radial tint + LED pip. .sidebar-foot
  contains a live CPU/FPS meter plate.
- Sidebar collapses to a 56 px icon rail at <=1100 px and hides via
  display:contents at <=600 px so mobile.css's fixed bottom tab-bar
  flows through unchanged.

Cards and dashboard:
- .card gets channel stripe (data-card-type + .ch-* utilities auto-map
  from data-target-id / data-stream-id / data-automation-id etc.), corner
  bracket, gradient background, subtle rack shadow.
- .card-running replaces the old @property --border-angle conic-gradient
  rotating border with a lightweight signalFlow linear-gradient strip on
  the bottom edge (cheaper paint, no GPU layer compositing per card).
- Skeleton loaders rewritten: left hairline + corner bracket + gradient
  shimmer instead of the old text-color opacity pulse.
- .dashboard-target rows pick up the same channel-stripe + signalFlow
  treatment. Section headers use mono micro-caps with a channel-green
  underline accent consistent across the app.
- .perf-chart-card: channel stripe replaces old border-top; per-metric
  accents moved to the channel palette (CPU=coral, RAM=violet, GPU=green,
  temp=amber). Metric values use tabular-nums + a soft glow.

Live bindings (no new endpoints):
- _updateSidebarMeter: binds the sidebar Load + FPS bars to the existing
  /system/performance poll.
- _updateTransportStatus: toggles the transport chip between "Ready" and
  "Armed - N live" whenever the dashboard's running-target set is
  recomputed.

Tree-nav + sub-tabs:
- tree-nav.css trigger pill gets a channel-stripe left edge that glows
  when open; panel has a gradient channel-accent rule across the top;
  group headers use silkscreened micro-caps; active leaf has a pulsing
  LED pip + channel tint.
- .stream-tab-btn / .subtab-section-header adopt the same mono-caps +
  channel-underline language for consistency.
- Graph editor toolbar gets gradient + hairline + rack shadow + backdrop
  blur. Canvas and nodes untouched.

Modals (40+ modals share modal.css):
- Radial-dim + 6 px blur backdrop. Content gets a gradient background,
  hairline border, deep rack shadow, top channel-accent rule driven by
  --modal-ch, bottom-right corner bracket (hidden on mobile fullscreen).
- Per-modal-ID channel lanes: target editors = green, source/input
  editors = cyan, audio = magenta, automation/scene/game = violet,
  settings/auth = amber, confirm = coral.
- Modal headers: vertical channel stripe left of the title + hairline
  divider. Modal footers: hairline top border + subtle gradient wash.

Forms:
- Inputs use hairline borders; number inputs switch to mono + tabular-nums
  for column alignment. Focus state: channel-green ring + soft glow.
- Buttons use mono-uppercase type with signal-glow on primary and coral-
  glow on danger.

Mobile (<=600 px):
- Fixed bottom .tab-bar gets the full Lumenworks treatment: gradient fill,
  top channel-accent rule matching the transport bar, backdrop blur.
  Active tab has an LED pip above the icon + channel tint + icon recolor.
- Fullscreen modals: corner bracket hidden, header stripe slimmed.

Microcopy (en / ru / zh):
- "Targets" -> "Channels" / "Каналы" / "通道"
- "Sources" -> "Inputs"    / "Входы"   / "输入"
- Internal tab keys (dashboard/automations/targets/streams/integrations/
  graph) kept stable so no JS or localStorage migration is needed.
- Added: sidebar.workspaces, sidebar.load, sidebar.fps,
  transport.status.ready, transport.status.armed.

Compatibility:
- All existing class hooks preserved (.tab-bar, .tab-btn, .card,
  .card-running, .tree-dd-*, .cs-*, .perf-chart-card, .modal-content,
  .dashboard-target, etc.). No JS or API changes required for the new
  look to take effect.
- Tour selectors survive (header .header-title, #tab-btn-*, onclick
  markers on theme/settings/search, #cp-wrap-accent, etc.).
- Mobile <=600 px bottom tab-bar keeps working via display:contents
  fall-through in the new sidebar.

Build: tsc --noEmit clean; npm run build clean. CSS bundle grew from
~177 KB to ~201 KB for the full new visual system. Fonts loaded lazily
per unicode-range subset (~98 KB critical path for English).

Phased plan + deferred follow-ups (dashboard hero strip, legacy-token
cleanup) recorded at the top of TODO.md.

Reference mockup: server/docs/ui-redesign-mockup.html.
2026-04-24 15:46:47 +03:00
alexei.dolgolyov c44bb38c43 docs(release): refresh v0.4.2 notes with fix(release) and refactor commits
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 2m5s
Build Release / build-linux (push) Successful in 4m53s
Build Release / build-docker (push) Successful in 5m39s
Build Release / build-windows (push) Successful in 6m55s
Lint & Test / test (push) Successful in 7m13s
2026-04-22 20:20:30 +03:00
alexei.dolgolyov be2d5e1670 refactor(color-strips): move Key Colors test from lightbox into test-css-source modal
Lint & Test / test (push) Successful in 6m37s
Removes the inlined FPS select and auto-refresh button from the shared
image lightbox and rehosts the Key Colors live preview inside the
dedicated test-css-source modal alongside the other CSS test views.

- Drop initLightbox() / lightbox-fps-select IconSelect — the lightbox no
  longer owns streaming controls.
- Add #css-test-kc-view (canvas + meta) and .css-test-kc-* styles.
- Reroute _testKeyColorsSource() through the existing modal session
  lifecycle so KC, CSPT, and standard CSS tests share teardown paths.
2026-04-22 20:18:46 +03:00
alexei.dolgolyov 5db6eddcf8 fix(release): ship prebuilt assets and bump fallback version
Two release-blocking bugs traced to the same root cause: the unanchored
`data/` rule in .gitignore matched server/src/ledgrab/data/, which is
where shipped package assets live (prebuilt sounds, game adapters).
The files were never `git add`-able without -f, so they never reached
the v0.4.2 tag and CI builds couldn't include them.

- .gitignore: anchor /data/ and /server/data/ so nested package data
  dirs are not ignored.
- Track previously-excluded shipped assets:
  - server/src/ledgrab/data/prebuilt_sounds/{alert,bell,chime,ping,pop}.wav
  - server/src/ledgrab/data/game_adapters/{minecraft,rocket_league,valorant}.yaml
- Bump _FALLBACK_VERSION 0.3.0 -> 0.4.2 to match pyproject.toml.
  The Windows installer strips ledgrab-*.dist-info, so
  importlib.metadata falls back to this literal — which is why
  v0.4.2 reports v0.3.0 in the WebUI.
- Patch _FALLBACK_VERSION at bundle time in build-common.sh and
  build-dist.ps1 so future drift is auto-corrected by the build.
2026-04-22 20:17:10 +03:00
alexei.dolgolyov a8a4296a56 chore: release v0.4.2
Build Android APK / build-android (push) Failing after 1m48s
Build Release / create-release (push) Successful in 3s
Build Release / build-linux (push) Successful in 3m58s
Build Release / build-docker (push) Successful in 5m6s
Build Release / build-windows (push) Successful in 5m54s
Lint & Test / test (push) Successful in 6m14s
2026-04-22 19:48:37 +03:00
alexei.dolgolyov 9ce1dc33bf feat(ui): restyle enhanced header locale picker as LED-accent badge 2026-04-22 19:48:08 +03:00
alexei.dolgolyov 03d2e6b1f2 ci(release): publish .sha256 sidecars alongside release assets
Lint & Test / test (push) Successful in 2m4s
The in-app update service (`ledgrab.core.update.update_service`) refuses
to install any downloaded artifact that has no published sha256 — either
as a sibling `<asset>.sha256` asset on the Gitea release, or embedded in
the release body. The release workflow uploaded the ZIP, setup.exe, and
Linux tarball but never published checksums, so every auto-update 500'd
with "Update checksum unavailable; install aborted".

Generate sha256sum sidecars for the Windows ZIP, Windows setup.exe, and
Linux tar.gz and upload them next to the primary asset on each tagged
release. Existing v0.4.x releases stay broken — ship v0.4.2 (or manually
upload sidecars to v0.4.1) to unblock in-app updates.
2026-04-22 19:40:46 +03:00
alexei.dolgolyov c2c9af3c60 chore: release v0.4.1
Build Release / create-release (push) Successful in 4s
Build Android APK / build-android (push) Failing after 1m41s
Build Release / build-linux (push) Successful in 3m3s
Build Release / build-docker (push) Successful in 4m13s
Lint & Test / test (push) Successful in 5m24s
Build Release / build-windows (push) Successful in 5m33s
2026-04-22 19:21:27 +03:00
alexei.dolgolyov 4f7794ccd4 fix(installer): bundle cryptography + just-playback, set TCL env, clean stale debug.bat
Lint & Test / test (push) Successful in 2m20s
Windows installer silently failed to launch because build-dist-windows.sh
maintained its own DEPS list that drifted from server/pyproject.toml and
was missing `cryptography` — ledgrab.utils.secret_box imports AESGCM at
module load, so pythonw.exe crashed before the tray icon appeared. Also
missing: just-playback (lazy import, silent until a sound triggers).

- Add cryptography + just-playback to DEPS with a sync-with-pyproject
  warning comment
- Extend the post-cleanup on-disk check to abort the build if
  cryptography / cffi / just_playback go missing again
- Launcher now exports TCL_LIBRARY / TK_LIBRARY so the screen-overlay
  tkinter thread stops logging "Can't find init.tcl" at startup
- Installer wipes stale debug.bat / debug.log on install and uninstall
  (leftovers from the pre-rename wled_controller era produced a
  misleading ModuleNotFoundError when users tried to diagnose launch
  failures)
2026-04-22 19:19:07 +03:00
alexei.dolgolyov a0d63a3663 docs(release): drop stale WLED-rename task, document android signing secrets
Lint & Test / test (push) Successful in 2m1s
- Remove the top-of-file "IMPORTANT: Remove WLED naming throughout the
  app" checklist. The effort was absorbed by the multi-backend refactor
  (BLE / USB-serial / ESP-NOW / MQTT / OpenRGB providers all shipped),
  and the remaining user-facing copy has been swept in separate commits.
- Add an "Android Signing Secrets (Gitea)" section covering the four
  secrets the release APK CI expects, the one-off `keytool` command to
  generate `release.jks`, the consequences of losing the keystore, and
  a checklist of the remaining setup steps before tagging v0.4.1.
2026-04-21 20:01:26 +03:00
alexei.dolgolyov 35b75a2ed8 ci(android): fix keystore env scoping, fail loudly on release without key
Lint & Test / test (push) Successful in 3m17s
Primary bug — step-level env is not visible in that same step's `if:`
expression. `Decode signing keystore` had
  if: env.ANDROID_KEYSTORE_BASE64 != ''
  env:
    ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
so the env context seen by the `if:` evaluator was empty regardless of
whether the secret was configured. The step was skipped, keystore.present
never became 'true', and every release tag silently fell back to
assembleDebug. Result: APKs named `LedGrab-0.4.0-android-debug.apk` that
can't upgrade a previously-release-signed install (signature mismatch).

Fix — move ANDROID_KEYSTORE_BASE64 to the job-level env block. It's now
resolvable in the if-expression of any step in the job, and the shell
inherits it exactly the same way as before.

Secondary — add a "Guard release tag against missing keystore" step that
fires between the decode attempt and the gradle build. If is_release=true
but keystore.present!='true', the job fails with a clear error directing
the operator to configure the four signing secrets. Previously a
misconfigured Gitea silently shipped debug APKs labeled as releases.
2026-04-21 19:55:55 +03:00
alexei.dolgolyov 4ed099d564 docs(release): drop WLED-specific language from auto-generated release notes
The "discover your WLED devices" line predates BLE / USB-serial / ESP-NOW /
MQTT / OpenRGB support and misrepresents what the app does. Replaced with
a generic "add your LED devices" — the device-add UI lists what's actually
supported, and INSTALLATION.md carries the long-form detail.
2026-04-21 19:55:55 +03:00
alexei.dolgolyov d467eb5dae chore: release v0.4.0
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Successful in 5m57s
Build Release / build-linux (push) Successful in 5m44s
Build Release / build-docker (push) Successful in 7m51s
Lint & Test / test (push) Successful in 8m59s
Build Release / build-windows (push) Successful in 8m51s
2026-04-21 19:41:40 +03:00
alexei.dolgolyov 524e422517 ci: decouple android release attach, add workflow_dispatch to release.yml
Lint & Test / test (push) Successful in 2m16s
build-android.yml
- Attach step upserts the Gitea release: GET /releases/tags/<TAG>, and
  POST to create it on 404 instead of warning-and-skipping. Removes the
  ordering dependency on release.yml's create-release job — the Android
  workflow can now own its own release attachment end-to-end.
- Fail loudly on broken DEPLOY_TOKEN: curl -f on every asset call so
  403/422 surface as job failures instead of "Uploaded" lies, and an
  explicit check that the token is non-empty before starting.
- Preserve the pre-existing replace-on-re-run behavior for idempotent
  asset uploads.

release.yml
- Add workflow_dispatch trigger with optional `version` input so the
  Windows/Linux/Docker builds can be exercised on demand between real
  releases (was tag-push only).
- Gate create-release on github.event_name == 'push' so a manual
  dispatch doesn't create a stray Gitea release.
- Each build job gets `if: !cancelled() && (needs.create-release.result
  in (success, skipped))` so dispatch runs still produce artifacts even
  though create-release was skipped.
- Gate each "Attach * to release" step on github.event_name == 'push'.
- Docker: login + push are push-only; build runs on both triggers so
  dispatch validates the Dockerfile without needing registry creds.
2026-04-21 19:10:14 +03:00
alexei.dolgolyov 5d6310f28c fix(android): make wheels find-links URL work on Linux CI
Lint & Test / test (push) Successful in 3m31s
Chaquopy's pip --find-links argument was built with a hard-coded
"file:///" prefix plus rootDir.absolutePath. That works on Windows
(paths start with "C:/...", so prefix + path produces three slashes
after "file:") but breaks on Linux (paths start with "/workspace/...",
so prefix + path produces four slashes — pip then parses "workspace"
as the URL's hostname and aborts with
  "ValueError: non-local file URIs are not supported on this platform".

Pick the prefix based on whether the absolute path already starts with
"/", so we always end up with exactly three slashes between "file:" and
the drive letter or root.
2026-04-21 18:58:17 +03:00
alexei.dolgolyov 7ef17c1595 ci(android): fix missing python symlink parent, restrict to release tags
Lint & Test / test (push) Successful in 3m53s
- Create android/app/src/main/python before `ln -sfn` — the parent dir
  isn't committed (android/.gitignore:17 ignores /ledgrab inside it) so
  fresh CI checkouts had nothing to link into, failing with
  "No such file or directory".
- Also wrap the step with `set -euo pipefail` and a `test -d` check so a
  broken link aborts the job instead of producing a silently-empty APK.
- Drop the `branches: [master]` push trigger and the `paths:` filter —
  every master commit touching android/** or server/src/ledgrab/** was
  queueing a ~100 MB APK build that nobody used. Only tag pushes (v*)
  and workflow_dispatch remain.
2026-04-21 18:53:43 +03:00
alexei.dolgolyov b3775b2f98 feat(android): boot-time autostart, capture watchdog, versionCode from git
Boot-time startup so LedGrab has display capture and control without user
interaction on rooted TV boxes. Also folds in a batch of review findings
from the Android package audit.

Autostart
- BootReceiver fires on BOOT_COMPLETED / LOCKED_BOOT_COMPLETED /
  MY_PACKAGE_REPLACED, gated by AutostartPrefs and Root.looksRooted().
  Dispatches CaptureService.createRootIntent via
  ContextCompat.startForegroundService. Unrooted devices are a no-op
  because MediaProjection consent cannot be bypassed silently.
- AutostartPrefs: thin SharedPreferences wrapper, defaults to enabled.
  Exposed as a CheckBox on the stopped panel; greyed out when not rooted.
- Manifest: RECEIVE_BOOT_COMPLETED, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
  WAKE_LOCK permissions + the new BootReceiver.
- MainActivity prompts for battery-optimization exemption on first opt-in
  so Doze/App Standby doesn't kill the FG service on phones.

Service stability
- onStartCommand now flips isRunning only after startForeground succeeds
  (was stuck=true forever if the FG transition threw) and resets on
  exception. Returns START_REDELIVER_INTENT for root mode so the OS can
  restart the service with the original intent (no consent token to
  invalidate); MediaProjection mode keeps START_NOT_STICKY.
- Watchdog coroutine monitors RootScreenrecord.framesDelivered. Respawns
  the pipeline on stall (reusing the existing Python bridge — no server
  restart), caps at 3 consecutive restarts before giving up.
- RootScreenrecord.framesDelivered is now an AtomicInteger, exposed as a
  public property for the watchdog.
- ScreenCapture takes an onProjectionStopped lambda; when the user taps
  the system Cast/Screen-capture stop banner, the whole service is torn
  down instead of leaving a stale FG notification.
- MainActivity's two startForegroundService calls switch to
  ContextCompat.startForegroundService, clearing pre-existing NewApi lint
  errors (minSdk=24 < API 26 native method).

Build
- versionCode derived from git rev-list --count HEAD (or the
  ANDROID_VERSION_CODE env var for CI). Was pinned to 1 — sideload
  upgrades were silently refusing to install.
- New i18n strings (autostart_label, autostart_unavailable, version_prefix)
  in en/ru/zh; version_text now uses the resource instead of string
  concat.

TODO.md: new "Android Autostart on Boot" section tracking done/pending
items; real-hardware verification on a Magisk'd TV box is the remaining
checkbox.
2026-04-21 18:53:27 +03:00
alexei.dolgolyov 45f93fd30e fix(devices): SP110E vendor handshake + Windows/bleak robustness
Build Android APK / build-android (push) Failing after 1m38s
Lint & Test / test (push) Successful in 4m32s
SP110E peripherals silently tear down the GATT link ~1s after connect
unless a two-write vendor handshake (01 00 → FFE2, 01 B7 E3 D5 → FFE1)
arrives immediately. Without it the first real write hangs 30s then
reconnect-loops forever. Adds optional BLEProtocol.init_writes executed
on connect, plumbs a per-write char_uuid through both transports, and
fixes the SP110E color/power frames from an incorrect 5 bytes to the
documented 4 bytes.

Windows/WinRT robustness:
- asyncio.wait_for hangs on bleak because WinRT IAsyncOperations refuse
  to cancel. _bounded_await() uses asyncio.wait() instead so timeouts
  actually return control even when the inner task is uncancellable.
- BleakClient connect by raw MAC string times out when WinRT guesses
  address type wrong; switched to pre-scanning with BleakScanner and
  passing the resolved BLEDevice, which carries the address type.
- Target-start fetch timeout bumped to 30s with retry disabled so the
  UI doesn't abort during the BLE pre-scan + connect + handshake path.

UI:
- Settings modal exposes Protocol Family (IconSelect grid, shared with
  add-device via parameterized ensureBleFamilyIconSelect) so users can
  fix a wrong family pick without recreating the device. Govee AES key
  row toggles on/off with family selection.

Also turns LAN auth back on in default_config.yaml, logs start_processing
requests on entry for easier diagnosis, and captures the full debug trail
in docs/BLE_LED_CONTROLLERS.md for future BLE work.

Refs the mbullington SP110E protocol gist for the handshake bytes.
2026-04-21 17:45:21 +03:00
alexei.dolgolyov 2b5dac2c42 feat(devices): BLE LED controller support (SP110E/Triones/Zengge/Govee)
Build Android APK / build-android (push) Failing after 1m44s
Lint & Test / test (push) Successful in 4m22s
End-to-end BLE streaming: provider + client + per-protocol wire encoders
with whole-strip averaging, desktop (bleak) and Android (Kotlin BleBridge
via Chaquopy) transports, discovery with protocol-family detection that
auto-fills the UI, throttled not-connected warning + 10 s reconnect
cooldown so a dropped link no longer stalls the pipeline at ~30 s/frame,
and an explicit asyncio.wait_for wrapper around bleak connect() since
the WinRT backend doesn't always honor the timeout kwarg.

Also rewrites server/restart.ps1 to be parameterized (-Port / -Module /
-PythonVersion / timeouts / -Quiet), pick the right interpreter via the
py launcher, pre-flight the target module, poll port readiness on both
shutdown and startup, redirect child stdout/stderr so Start-Process
doesn't hang on inherited Git-Bash handles, and return proper exit codes.

Rolls in concurrent work: Android BLE permissions + launcher icons + ru/zh
resources, Chaquopy-safe value_stream psutil fallback, setup-required
modal, asset-store test coverage, and misc system/config touch-ups.
2026-04-21 14:58:35 +03:00
alexei.dolgolyov d3a6416a1d refactor(devices): per-provider typed configs (phases 1-4)
Phase 1 — DeviceConfig hierarchy (device_config.py):
- 17 @dataclass(frozen=True) subclasses (WLEDConfig, AdalightConfig, …) sharing
  BaseDeviceConfig; DeviceConfig = Union[all 17]
- Device.to_config() in device_store.py: single flat→typed dispatch point

Phase 2+3 — Typed provider signatures + call-site migration:
- ProviderDeps(device_store) frozen dataclass in led_client.py
- LEDDeviceProvider.create_client(config, *, deps) abstract signature
- create_led_client(config, *, deps) factory dispatches via config.device_type
- All 17 providers narrowed to their specific config type; drop kwargs.get()
- GroupLEDClient.connect() uses device.to_config() + create_led_client()
- wled_target_processor: replaced 21-field DeviceInfo unpacking with to_config()
  + dataclasses.replace(config, use_ddp=…) for DDP override
- device_test_mode: build typed config via to_config() + ProviderDeps
- Deleted DeviceInfo dataclass, _get_device_info(), _DEVICE_FIELD_DEFAULTS
- TargetContext: replaced get_device_info callback with is_test_mode_active

Phase 4 — Test migration:
- 47-case test suite in tests/core/devices/test_device_config.py (100% coverage)
- test_group_device.py TestGroupLEDClient migrated to GroupConfig + ProviderDeps
- Removed legacy keyword-arg init path from GroupLEDClient
2026-04-18 01:24:27 +03:00
alexei.dolgolyov 123da1b5c4 fix: comprehensive security, stability, and code quality audit
Build Android APK / build-android (push) Failing after 1m45s
Lint & Test / test (push) Successful in 4m54s
Security:
- Force API key auth for LAN (non-loopback) requests; remove shipped dev key
- Block path-traversal in backup restore; require auth on backup endpoints
- SSRF protection: DNS resolve + private/loopback/link-local IP rejection
- AES-256-GCM encryption for HA tokens and MQTT passwords with auto-migration
- WebSocket auth migrated from query-string to first-message protocol
- Asset upload: extension allowlist, server-side mime, Content-Disposition
- Update installer: SHA256 verification, tar/zip member validation
- Tightened CORS (explicit methods/headers, no credentials)
- ADB serial regex allowlist, webhook rate-limit key fix, log scrubbing

Android:
- Root-capture: ordered teardown, screenrecord respawn watchdog, child reaping
- USB permission blocking API via CompletableDeferred
- Python init crash guard with fatal-error screen
- Moved root grant + QR generation off Main thread
- Cached PyObject engine for per-frame bridge calls
- Ordered ScreenCapture resource cleanup, allowBackup=false

Python:
- Replaced all asyncio.get_event_loop() with get_running_loop/to_thread
- Split color_strip_sources.py (1683->5 files) and color_strip_stream.py
  (1324->7 files) into packages
- Extracted FrameLimiter utility, migrated 9 stream loops
- Provider base-class reuse, WLED state caching + URL normalization
- Narrowed broad except-pass in WS routes, threading fixes in BaseStore

Frontend:
- XSS fix: escapeHtml on dynamic option labels, reconcile-based list renders
- Typed DOM helpers, safe localStorage access, AbortController listener hygiene
- openAuthedWs helper for first-message WS auth protocol
- Migrated remaining plain <select>s to IconSelect/EntitySelect

Design:
- WCAG AA primary color on light theme (#2e7d32, 5.4:1 contrast)
- Android TV 10-foot breakpoint (tv.css)
- Consolidated z-index tokens, unified easing, card-running GPU hints
2026-04-16 04:56:04 +03:00
alexei.dolgolyov 5fcb9f82bd feat(android): root-based screen capture bypassing MediaProjection
Build Android APK / build-android (push) Failing after 1m40s
Lint & Test / test (push) Successful in 3m40s
On rooted TV boxes, spawn `su -c screenrecord ... -` and feed the
H.264 stdout through MediaCodec into an ImageReader, surfacing RGBA
frames via PythonBridge. RootScreenrecordEngine (priority 110) is
picked automatically when root is available; falls back to
MediaProjection when Root.requestGrant() returns false.
2026-04-14 19:30:26 +03:00
alexei.dolgolyov 928d626620 refactor(devices): route ESP-NOW client through SerialTransport
Build Android APK / build-android (push) Failing after 1m39s
Lint & Test / test (push) Successful in 4m54s
Drops the direct pyserial imports from espnow_client/espnow_provider
in favor of open_transport/list_serial_ports/port_exists. The gateway
protocol is write-only, so no read() extension was needed. ESP-NOW
gateways are now reachable via usb:VID:PID URLs on Android.
2026-04-14 19:15:08 +03:00
alexei.dolgolyov 580bd692e6 fix(scenes): coerce BindableFloat fps to int when snapshotting
Build Android APK / build-android (push) Failing after 1m41s
Lint & Test / test (push) Successful in 4m32s
OutputTarget.fps is a BindableFloat but TargetSnapshot.fps is a plain
int — capture_current_snapshot was stuffing the BindableFloat directly
into the snapshot, causing json.dumps to fail on recapture (500) and
polluting the in-memory cache so subsequent list calls also 500'd with
pydantic validation errors.
2026-04-14 19:03:58 +03:00
alexei.dolgolyov 7fcb8dd346 feat(devices): Android USB-serial support for Adalight/AmbiLED controllers
Build Android APK / build-android (push) Failing after 1m41s
Lint & Test / test (push) Successful in 4m51s
Adds end-to-end support for driving USB-connected Adalight / AmbiLED
LED controllers from Android TV boxes. Android's security model blocks
direct USB access from Python, so writes route through a Kotlin
UsbSerialBridge singleton via Chaquopy.

Python side:
- New SerialTransport Protocol (serial_transport.py) with open / write /
  flush / close. Desktop uses PySerialTransport (wraps pyserial),
  Android uses AndroidSerialTransport (wraps the Kotlin bridge).
- list_serial_ports() factory returns desktop COM ports on desktop,
  USB devices on Android — callers don't branch.
- URL scheme extended: existing COM3[:baud] and /dev/ttyUSB0[:baud]
  unchanged; new usb:VID:PID[:serial][@baud] for Android (@ is the
  baud separator since : is already used between VID and PID).
- AdalightClient and SerialDeviceProvider refactored to go through
  the transport — no more direct pyserial imports in hot paths.
- 17 new unit tests cover URL parsing, PySerial transport, factory
  selection, platform-branching discovery. Full suite 750 passing.

Kotlin side:
- UsbSerialBridge.kt singleton uses com.hoho.android.usbserial (mik3y)
  which ships drivers for CH340, CP2102, FTDI, Prolific, and CDC-ACM
  (Arduino). Exposes listDevices, open, write, close via @JvmStatic
  for Chaquopy. First open() attempt without permission triggers the
  system USB permission dialog; next call succeeds once user grants.
- usb-serial-for-android is distributed via JitPack — added that repo
  in settings.gradle.kts and the dependency in app/build.gradle.kts.
- AndroidManifest declares uses-feature android.hardware.usb.host
  (required=false so non-USB-host phones still install).
- LedGrabApp.onCreate calls UsbSerialBridge.init(this) so the bridge
  resolves the UsbManager without needing an Activity ref.

Verified: ./gradlew compileDebugKotlin succeeds; off-Android import
of android_serial_transport works. Real-hardware smoke test on a
TV box with a CH340/CP2102/FTDI adapter still pending.

ESP-NOW (espnow_client / espnow_provider) still imports pyserial
directly because it needs bidirectional reads — separate refactor
to extend the transport with read() if that path ever needs Android
USB support.
2026-04-14 16:34:09 +03:00
alexei.dolgolyov ecae05d00b feat(metrics): battery + thermal-zone readings with dashboard temp chart
Build Android APK / build-android (push) Failing after 1m40s
Lint & Test / test (push) Successful in 4m18s
Extends MetricsProvider with thermals() returning a ThermalSnapshot
(battery_percent, battery_temp_c, cpu_temp_c — all optional). Each
provider implements it independently:

- AndroidMetricsProvider reads /sys/class/power_supply/battery/{capacity,
  temp} (battery temp is tenths of degC) and walks
  /sys/class/thermal/thermal_zone*, filtering by zone type
  (cpu/soc/tsens/core) so battery and skin sensors don't dominate the
  reading. Rejects nonsense values like INT_MAX from buggy zones.
- PsutilMetricsProvider uses sensors_battery() and
  sensors_temperatures() when present (Linux+laptops); no-ops on
  Windows/macOS where psutil doesn't expose them.
- NullMetricsProvider returns the empty snapshot.

PerformanceResponse gains battery_percent / battery_temp_c / cpu_temp_c.
The metrics-history ring buffer also carries cpu_temp / battery_pct /
battery_temp per sample so the dashboard can graph them over time.

Frontend dashboard (perf-charts.ts) gets a new Temperature chart card,
hidden by default and revealed only after seed/poll confirms the
backend reports cpu_temp_c. Battery temperature shows inline as a
secondary badge. The GPU card now also hides entirely when the backend
reports gpu=null instead of showing an "unavailable" placeholder.
HOST_ONLY_KEYS prevents the System/App/Both toggle from flipping a
non-existent app dataset for temp.

Tests: 6 new for thermals (battery tenths-of-degC parsing, CPU zone
filtering, fallback when sensors absent, INT_MAX rejection); 18 metrics
tests total; full suite 733 passing.
2026-04-14 13:48:01 +03:00
alexei.dolgolyov 546b24d015 refactor(metrics): MetricsProvider abstraction with Android /proc backend
Build Android APK / build-android (push) Failing after 1m39s
Lint & Test / test (push) Successful in 4m20s
Moves direct psutil.* calls behind a MetricsProvider Protocol so the
codebase no longer needs ad-hoc `if psutil is not None` guards at every
call site. Each provider lives in its own module under
utils/metrics/: PsutilMetricsProvider for desktop, NullMetricsProvider
as a zeroed fallback, AndroidMetricsProvider that reads /proc/stat,
/proc/meminfo, /proc/self/stat, and /proc/self/status directly (psutil
isn't available under Chaquopy). The Android provider tracks the
previous CPU sample so cpu_percent() returns delta-based percentages
matching psutil's interval=None semantics, and degrades to zeros when
any /proc file is unreadable instead of crashing the dashboard.

Factory get_metrics_provider() in utils/metrics/__init__.py picks
Android > psutil > Null. api/routes/system.py and
core/processing/metrics_history.py now go through the factory; psutil
import is confined to one place. 12 new unit tests cover paren-in-comm
parsing of /proc/self/stat, delta CPU%, missing-file resilience, and
factory selection order. Full suite: 727 passing.
2026-04-14 13:34:32 +03:00
alexei.dolgolyov 488df98996 fix(frontend): add autocomplete attrs to credential inputs
Build Android APK / build-android (push) Failing after 1m40s
Lint & Test / test (push) Successful in 3m35s
Silences browser accessibility warnings on the HA token, MQTT username,
and MQTT password fields. Uses new-password for the secret inputs to
discourage Chrome's site-password autofill from leaking into broker /
HA-token configuration.
2026-04-14 12:50:50 +03:00
alexei.dolgolyov 2477e00fae ci: add Android APK row to release downloads table
Lint & Test / test (push) Successful in 1m37s
2026-04-14 12:47:27 +03:00
alexei.dolgolyov 151cea3ecb ci: Android multi-ABI APK pipeline + pydantic-core wheel rebuild
Build Android APK / build-android (push) Failing after 2m31s
Lint & Test / test (push) Successful in 6m15s
Adds .gitea/workflows/build-android.yml — Linux runner installs JDK 17,
Python 3.11, Android SDK/NDK, symlinks server/src/ledgrab into the
Chaquopy python source dir, and runs assembleDebug on master pushes /
assembleRelease on v* tags. APK is uploaded as an artifact and attached
to the Gitea release on tag push. Conditional signing config in
build.gradle.kts reads keystore from env vars (CI secrets) and falls
back to debug signing locally. Gradle wrapper (gradlew/gradlew.bat/
gradle-wrapper.jar) committed so CI can drive the build.

Rebuilds pydantic-core wheels for arm64-v8a and x86_64 — both were
missing libpython3.11.so in NEEDED, which would have crashed at import
on real devices. build-pydantic-core.sh rewritten as a multi-ABI builder:
selects targets via args, sets RUSTFLAGS=-C link-arg=-Wl,--no-as-needed
-C link-arg=-lpython3.11 to force the symbol-resolution dependency,
uses the per-ABI sysconfigdata + libpython staged in
android/.build-cache/, prefers `py -3.11` on Windows (Git Bash's
python3.11 is an MSStore stub), uses the .cmd clang wrapper on Windows
(fixes os error 193), and verifies NEEDED via llvm-readelf after each
build. abiFilters restored to the full triple in build.gradle.kts;
multi-ABI debug APK builds cleanly (~99 MB).
2026-04-14 12:36:13 +03:00
alexei.dolgolyov 8574424fb7 feat: Android TV app embedding Python server via Chaquopy
Lint & Test / test (push) Successful in 2m10s
Adds a native Android TV application that runs the full LedGrab Python
server in-process via Chaquopy. Captures the TV box screen using the
MediaProjection API and exposes the existing web UI on the device's
local network — users configure via phone/tablet browser.

Android (new /android/ module):
- Kotlin shell: MainActivity, CaptureService (foreground service),
  ScreenCapture (MediaProjection + ImageReader), PythonBridge (Chaquopy).
- Polished Leanback-themed UI with QR code for easy web UI access.
- AGP 8.9 + Chaquopy 17 + Gradle 8.11 (avoids the AGP 8.7 thread-lock bug).
- Pre-built pydantic-core wheels for arm64-v8a, x86_64, x86 cross-compiled
  with maturin + Android NDK, linked against Chaquopy's libpython3.11.so.

Python server platform guards:
- New utils/platform.py with is_android()/is_windows()/is_linux() helpers.
- Guard every top-level import of desktop-only packages (mss, psutil,
  sounddevice, pyserial, PyAudioWPatch, etc.) with try/except ImportError.
- Android-incompatible calls gated with None-checks so the server runs on
  reduced capabilities on Android (no CPU/RAM metrics, no mss displays).
- utils/image_codec.py gains a Pillow fallback for resize + JPEG encode
  when cv2 is unavailable; all internal cv2.resize callers migrated.
- New android_entry.py start_server/stop_server invoked from Kotlin.
- get_displays API falls back to best available engine when mss fails.

New capture engines:
- MediaProjectionEngine: receives RGBA frames pushed from Kotlin through
  a thread-safe queue; caches last frame for static-screen previews.
- ScrcpyClientEngine: optional H.264 streaming via scrcpy-client library
  (priority 10, overrides the ADB-screencap engine when installed).

Frontend:
- Tab loaders previously required an apiKey; now correctly treat
  "auth disabled" as authenticated (Android has no auth by default).
- Re-trigger the active tab's loader after loadServerInfo resolves
  authRequired, since initTabs runs earlier.
- Add i18n keys for the demo / mediaprojection / scrcpy_client engines.

Docs:
- TODO.md: follow-ups for multi-ABI wheel rebuilds, CI pipeline, USB
  serial LED controllers, root-only capture, perf metrics abstraction.
- CLAUDE.md: Android dependency sync policy (pip --exclude doesn't exist).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 03:11:43 +03:00
alexei.dolgolyov a0b65e3fcb refactor: move build scripts to build/ directory
Lint & Test / test (push) Successful in 2m21s
Declutters the repo root by consolidating build-common.sh,
build-dist.sh, build-dist-windows.sh, build-dist.ps1, and installer.nsi
into build/. Updates all path references in CI workflows, NSIS installer,
and documentation.
2026-04-12 23:16:40 +03:00
alexei.dolgolyov 02cd9d519c refactor: rename project to LedGrab, split HA integration into separate repo
Lint & Test / test (push) Successful in 1m56s
- Rename Python package: wled_controller -> ledgrab
- Rename env var prefix: WLED_ -> LEDGRAB_ (with auto-migration for old vars)
- Rename localStorage key: wled_api_key -> ledgrab_api_key (with migration)
- Rename HA integration domain: wled_screen_controller -> ledgrab
- Update all imports, build scripts, Docker, installer, config, docs
- Remove HA integration (moved to ledgrab-haos-integration repo)
- Remove hacs.json (belongs in HA repo now)
- Add startup warning for users with old WLED_ env vars
- All tests pass (715/715), ruff clean, tsc clean, frontend builds
2026-04-12 22:45:28 +03:00
alexei.dolgolyov 38f73badbf fix: register pattern-templates API route; add responsive toolbar overflow menu
Lint & Test / test (push) Successful in 2m19s
Pattern templates route existed but was never wired into the router,
dependencies, or database table allowlist — causing 404 on graph tab load.

Graph toolbar now collapses secondary actions into a "more" overflow menu
on viewports narrower than 700px. Primary controls (fit, zoom, add) stay
visible; search, filter, panels, undo/redo, relayout, fullscreen, and
help move into the dropdown.
2026-04-12 21:22:50 +03:00
alexei.dolgolyov e678e5590a docs: update TODO and frontend context docs
Lint & Test / test (push) Successful in 1m54s
Replace TODO-css-improvements.md with TODO-release.md. Add EntityPalette
and IconSelect patterns for dynamic entity lists to frontend context.
2026-04-12 20:55:58 +03:00
alexei.dolgolyov 83ceaeda9d fix: HA Light Target cards flickering on every poll cycle
Lint & Test / test (push) Has been cancelled
Use stable placeholder values in card HTML for volatile metrics (fps,
uptime, HA status, entity swatches) so CardSection.reconcile() skips
unchanged cards. Actual values are patched in-place via
patchHALightTargetMetrics() — same pattern LED target cards already use.
2026-04-12 20:55:30 +03:00
alexei.dolgolyov d3cd48e7a7 fix: EntitySelect not showing selected value in weather/processed CSS editors
Lint & Test / test (push) Successful in 1m46s
After _populateWeatherSourceDropdown() and _populateProcessedSelectors()
create new EntitySelect instances, the subsequent .value = assignment on
the native <select> doesn't trigger _syncTrigger(), so the trigger
button still shows "—". Call .refresh() after setting the value.
2026-04-12 20:47:33 +03:00
alexei.dolgolyov cc9900d801 feat: support nesting for composite color strip sources
Lint & Test / test (push) Successful in 2m17s
Allow composite sources to reference other composite/mapped sources as
layers. Adds cycle detection (via transitive dependency graph walk),
depth limiting (MAX_COMPOSITE_DEPTH=4), and a runtime safety net in the
stream manager. Frontend layer dropdown now shows all source types
except the source being edited.

17 new tests covering cycles, depth limits, and valid nesting — all
715 tests passing.
2026-04-12 20:41:15 +03:00
alexei.dolgolyov 4940007e54 feat: add Group device type for combining multiple devices
Lint & Test / test (push) Successful in 2m19s
Introduces a new "group" device type that aggregates multiple physical
(or nested group) devices into one virtual device. Supports two modes:
- Sequence: LEDs concatenated end-to-end (led_count = sum of children)
- Independent: full pixel array resampled to each child independently

Includes cycle detection (DFS) to prevent circular group references,
delete protection for devices referenced by groups, recursive LED count
resolution for nested groups, and reorder controls (move up/down) for
child devices in the UI.

Backend: Device model, API schemas, GroupLEDClient, GroupDeviceProvider,
route validation, processing pipeline integration.
Frontend: type picker, child device picker with reorder, mode selector,
i18n (en/ru/zh), layers icon, CSS for group child rows.
Tests: 20 unit tests for cycle detection, LED count resolution, and
GroupLEDClient (sequence slicing, independent resampling, cleanup).
2026-04-11 02:26:56 +03:00
alexei.dolgolyov 92585e7c19 fix(build): bundle bettercam/dxcam/windows-capture in installer
Lint & Test / test (push) Successful in 2m5s
The Windows installer was only shipping mss as a screen-capture
backend, so EngineRegistry.get_available_engines() reported just
['mss', 'camera', 'demo'] on installed builds. Picture sources
configured to use bettercam/dxcam/wgc were rejected at the
test/ws handshake with HTTP 403 (close-before-accept on
"Engine '<x>' not available").

Add the three Windows screen-capture wheels to WIN_DEPS so the
installer build matches a 'pip install -e .' dev environment.
2026-04-08 23:15:10 +03:00
alexei.dolgolyov 0e09eaf43b fix(launcher): set TCL_LIBRARY/TK_LIBRARY for embedded Python
Embedded Python ships with tcl8.6/ and tk8.6/ next to python.exe, but
Tcl's auto-detection searches <exe>/../lib/tcl8.6 — a path that doesn't
exist in our layout. Without these env vars, tkinter.Tk() raises
"Can't find a usable init.tcl", breaking the screen overlay (and any
other tk-based UI) on installed builds.
2026-04-08 23:14:58 +03:00
alexei.dolgolyov adfc39f9d1 chore: release v0.3.0
Build Release / create-release (push) Successful in 3s
Build Release / build-linux (push) Successful in 1m23s
Build Release / build-docker (push) Successful in 2m19s
Lint & Test / test (push) Successful in 3m10s
Build Release / build-windows (push) Successful in 3m29s
2026-04-08 12:41:28 +03:00
alexei.dolgolyov d037a2e929 fix(tray): replace tkinter messagebox with Win32 MessageBoxW
Lint & Test / test (push) Successful in 2m3s
The packaged embedded Python distribution does not ship the tcl/tk
runtime, so tkinter.messagebox.askyesno crashed with 'Can't find a
usable init.tcl' when the user clicked Shutdown or Restart in the
tray menu. Use ctypes + user32.MessageBoxW instead — no tcl/tk,
no extra dependencies.
2026-04-08 12:16:32 +03:00
alexei.dolgolyov fc8ee34369 fix(launcher): start-hidden.vbs must be ASCII + CRLF, use python.exe
Lint & Test / test (push) Successful in 1m40s
Three separate bugs in the VBS launcher wedged together:

1. The previous fix added a UTF-8 em-dash in a comment. wscript.exe
   on Windows refused to execute the file with "Execution of the
   Windows Script Host failed. (Not enough memory resources are
   available to complete this operation.)" — a misleading error that
   actually means "I could not parse this file as ANSI VBScript".
   Fix: keep the file pure ASCII, convert to CRLF.

2. The launcher was invoking pythonw.exe. WshShell.Run spawning
   pythonw.exe inside the wscript host exited immediately (no process,
   no log). python.exe with WindowStyle=0 works reliably and matches
   the pattern used by the Media Server sibling app's VBS launcher,
   which has been running on this machine without issue.

3. The env vars (PYTHONPATH, WLED_CONFIG_PATH) must be set before the
   child process spawns, otherwise config.py falls back to the CWD
   default path that does not exist at install time.
2026-04-08 00:15:49 +03:00
alexei.dolgolyov e262a8b004 fix(launcher): set PYTHONPATH and WLED_CONFIG_PATH in start-hidden.vbs
Lint & Test / test (push) Successful in 2m1s
The VBS launcher (used by Start Menu, desktop, and autostart shortcuts
created by the NSIS installer) ran pythonw.exe without setting any env
vars. LedGrab.bat sets PYTHONPATH and WLED_CONFIG_PATH; the VBS did not.

With CWD set to the install root, config.py fell through to its default
lookup (./config/default_config.yaml), which does not exist there — the
real file is at app/config/default_config.yaml. The server silently ran
with built-in defaults on every shortcut launch: no devices, wrong data
dir, nothing persisted where the user expected.

The fix uses WshShell.Environment("Process") to set env vars on the
current VBS process, which child processes spawned via .Run inherit.
Kept CurrentDirectory = appRoot to preserve prior behavior for anyone
depending on CWD-relative paths inside the app.
2026-04-08 00:02:56 +03:00
alexei.dolgolyov d4ffe2e985 refactor: drop packaging dependency, inline version parsing
Lint & Test / test (push) Successful in 3m9s
The only user of 'packaging' was version_check.py — two small functions
(normalize_version, is_newer) that just need to parse "1.2.3-alpha.1"
and compare PEP 440-style versions. That's well within stdlib reach.

- Inline a NamedTuple-based Version with kind/pre_num ordering
  (dev < alpha < beta < rc < release), same regex-normalized format
- Define a local InvalidVersion exception
- Remove packaging>=23.0 from pyproject.toml dependencies

Why now: the Windows cross-build uses a hard-coded DEPS array in
build-dist-windows.sh, which was never updated when 'packaging' was
added on March 25. Result: importable from pip-installed dev envs,
missing from the portable installer — tray icon appeared but uvicorn
died with ModuleNotFoundError: No module named 'packaging'.

Removing the dep entirely is cleaner than adding one more hard-coded
entry to the Windows DEPS list. Tests (678 passing) and a manual test
matrix covering dev/alpha/beta/rc/release ordering all pass.
2026-04-07 23:54:27 +03:00
alexei.dolgolyov feb91ad281 fix(build): fix shell syntax error in smoke_test_imports heredoc
Lint & Test / test (push) Successful in 2m6s
The 'cmd <<EOF || { ... }' pattern confuses bash's parser — the closing
brace of the inline error block collides with the function's closing
brace. Rewrote to capture the python script into a local var via
$(cat <<EOF), then run it with -c and a plain 'if !' guard.
2026-04-07 23:41:42 +03:00
alexei.dolgolyov 17c5c02993 fix(build): keep .py sources + make smoke test skip uninstalled modules
Lint & Test / test (push) Successful in 1m36s
- compile_and_strip_sources: stop deleting .py files after compileall.
  OpenCV's loader does literal file I/O on cv2/config.py (not a Python
  import), so stripping it breaks `import cv2` with "missing
  configuration file: ['config.py']". Other packages may do similar
  file-based introspection tricks — the ~30% size win isn't worth
  playing whack-a-mole with broken installers. We already hit this
  with numpy.linalg and zeroconf._services; enough incidents.
- smoke_test_imports: only assert importability for modules whose
  top-level dir actually exists in site-packages. Pillow for example
  is a Windows-only dep, and was failing the Linux build spuriously.
  Rewrote as a heredoc for readability.
2026-04-07 23:37:54 +03:00
alexei.dolgolyov fd6776aeac fix(build): stop stripping zeroconf/_services + add import smoke test
Lint & Test / test (push) Successful in 2m39s
- build-common.sh: remove zeroconf/_services from the strip list.
  zeroconf's compiled Cython _listener.pyd imports from _services
  internally, so stripping it broke `import zeroconf` at runtime with
  ModuleNotFoundError — same class of bug as the numpy.linalg strip.
- build-common.sh: add smoke_test_imports() that imports every top-level
  dependency against the stripped site-packages. Catches "we stripped
  something that was actually needed" regressions at build time instead
  of on a user's machine after install.
- build-dist.sh: wire smoke test into the Linux flow (runs real imports).
- build-dist-windows.sh: cross-build can't load win_amd64 .pyd files with
  the host python, so instead verify that the known-required submodule
  dirs (numpy.linalg/lib/matrixlib/ma, zeroconf._services) exist after
  cleanup. Fails loud if any future strip-rule removes them.
2026-04-07 23:32:50 +03:00
alexei.dolgolyov 9f34ffb0a0 fix(build): stop stripping numpy.lib/linalg from site-packages
Lint & Test / test (push) Successful in 2m57s
numpy's own __init__.py imports lib and matrixlib (which in turn imports
numpy.linalg via defmatrix.py). Removing any of these submodules to save
dist size makes `import numpy` raise ModuleNotFoundError on the target,
which cascades into every wled_controller import.

Symptom: v0.0.0.dev0 Windows installer showed a tray icon but uvicorn
died silently in its background thread and port 8080 never listened —
browser got ERR_CONNECTION_REFUSED.

Keep stripping: polynomial, distutils, f2py, typing, _pyinstaller.
These are genuinely unused by numpy's own import chain.
2026-04-07 23:17:12 +03:00
alexei.dolgolyov b5842e6424 fix(build): normalize non-PEP440 versions, fix .py/compileall ordering, wipe NSIS payload dirs
Lint & Test / test (push) Successful in 3m18s
- build-common.sh: detect_version() normalizes non-PEP440 labels (e.g. 'dev',
  'nightly') to 0.0.0.dev0 so stamping pyproject.toml doesn't break pip install.
- build-common.sh: split .py deletion out of cleanup_site_packages into a new
  compile_and_strip_sources() that runs 'compileall -b' FIRST, then removes
  sources. compileall now fails loud instead of silently no-op'ing.
- build-dist.sh: add missing compile_and_strip_sources call on Linux site-packages
  (previous tarballs shipped empty packages with no .py and no .pyc).
- build-dist-windows.sh: reorder so compile_and_strip_sources runs right after
  cleanup_site_packages, not after .py files have already been deleted.
- installer.nsi: RMDir /r payload dirs (python/, app/, scripts/) and delete
  LedGrab.bat at the top of SecCore before File /r. NSIS File /r MERGES into
  existing dirs, so upgrades left half-old/half-new state that surfaced as
  'version mismatch' or duplicate-package ImportErrors. data/ and logs/ remain
  untouched to preserve user config.
2026-04-07 23:04:38 +03:00
alexei.dolgolyov 7a9c368448 refactor: split color-strips.ts into focused modules under color-strips/ folder
Lint & Test / test (push) Successful in 2m6s
Monolithic 3060-line color-strips.ts split into 11 modules:
- index.ts (core orchestrator, modal, type switching, editor, save, CRUD)
- cards.ts (card rendering for all source types)
- game-event.ts (game event mappings, presets, UI)
- gradient.ts (gradient entity modal + CRUD)
- audio.ts (audio viz widgets, load/reset state)
- math-wave.ts (wave layers + waveform selects)
- mapped.ts (mapped zone helpers)
- color-cycle.ts (color cycle add/remove/render)
- composite.ts, notification.ts, test.ts (previously extracted, moved into folder)
2026-04-05 12:54:15 +03:00
alexei.dolgolyov ce53ca6872 feat: add card glare effect to dashboard and perf chart cards
Lint & Test / test (push) Has been cancelled
Extend cursor-tracking spotlight to .dashboard-target and
.perf-chart-card elements with a smaller 100px radius suited
for compact cards.
2026-04-05 12:23:39 +03:00
alexei.dolgolyov b04978af58 feat: add music sync viz modes and auto_gain audio filter
Lint & Test / test (push) Has been cancelled
Add 4 new audio visualization modes powered by MusicAnalyzer:
- pulse_on_beat: BPM-synced pulsing with smooth beat phase
- energy_gradient: bass/mid/treble mapped to scrolling gradient
- spectrum_bands: three VU zones for frequency bands
- strobe_on_drop: state-driven strobe on detected musical drops

MusicAnalyzer provides BPM estimation (median IBI), beat phase tracking,
asymmetric energy envelope, 3-band frequency splitting, and drop
detection state machine (idle/buildup/drop/recovery).

Add auto_gain audio filter for automatic level normalization via rolling
peak tracking with configurable target level and response time.

Deprecate auto_gain on Audio Value Source (use the filter instead).
2026-04-05 01:40:34 +03:00
alexei.dolgolyov 6e8b159126 fix: weather CSS card shows empty source name after hard refresh
Lint & Test / test (push) Has been cancelled
Weather sources cache was not fetched during streams tab load, so the
card renderer could not resolve weather source names. Also widened
lat/lon inputs from 80px to 100px to fit longer coordinates.
2026-04-05 00:49:28 +03:00
alexei.dolgolyov ace24715c8 feat: add math_wave color strip source type
Lint & Test / test (push) Has been cancelled
Mathematical wave generator that produces per-LED colors from
configurable waveform layers (sine, triangle, sawtooth, square) with
superposition, mapped through a gradient palette. Supports sync clocks,
bindable speed, and up to 8 wave layers.

- Storage model with wave validation and apply_update
- Numpy-vectorized stream with gradient LUT color mapping
- API schemas (create/update/response) and route registration
- Frontend editor with dynamic wave layer rows, gradient picker,
  speed widget, and IconSelect waveform selectors
- i18n in en/ru/zh
2026-04-05 00:41:07 +03:00
alexei.dolgolyov edc6d27e2e fix: replace HA test icon with refresh, make automation rules collapsible
Lint & Test / test (push) Has been cancelled
Use refresh icon instead of flask for HA test connection buttons.
Add collapse/expand chevron to automation rule rows (collapsed by default).
2026-04-04 21:28:51 +03:00
alexei.dolgolyov b7da4ab6b5 feat: add Integrations tab and responsive icon-only tabs
Lint & Test / test (push) Successful in 1m48s
Move HA sources, weather sources, game integration, and MQTT settings
into a dedicated Integrations top-level tab with tab-registry pattern.
Collapse tab labels to icon-only at narrow desktop widths (<=1100px)
to prevent toolbar overflow.
2026-04-02 15:29:38 +03:00
alexei.dolgolyov 99460a8043 fix: make pystray a core dependency on Windows instead of optional extra
Lint & Test / test (push) Successful in 2m5s
The tray icon should always appear on Windows, not only when installed
with the [tray] extra.
2026-04-02 14:22:18 +03:00
alexei.dolgolyov 89990f8d63 chore: remove processed-audio-sources plan files 2026-04-02 13:42:37 +03:00
alexei.dolgolyov 0cc0aaa411 feat: processed audio sources with composable filter pipeline
Replace hardcoded MonoAudioSource/BandExtractAudioSource with a
composable ProcessedAudioSource + AudioProcessingTemplate + AudioFilter
system. 11 audio filters: channel extract, band extract, peak hold,
gain, noise gate, envelope follower, spectral smoothing, compressor,
inverter, beat gate, delay. Full frontend UI with filter editor,
tree navigation, and i18n support.
2026-04-02 13:42:18 +03:00
alexei.dolgolyov af2c89c8df fix: audio tree structure, filter i18n, and IconSelect for filter options
Restructure audio tree nav into Capture (Sources + Engine Templates)
and Processed (Sources + Filter Templates) subgroups.
Add missing i18n description keys for all 11 audio filters.
Wrap plain select filter options with IconSelect grids.
2026-04-02 13:37:50 +03:00
alexei.dolgolyov d04192ffb7 fix: add reference check before deleting audio processing template
Prevent deleting templates that are still referenced by
ProcessedAudioSource entities. Returns 400 with source names.
2026-04-01 23:28:58 +03:00
alexei.dolgolyov 992495e2e4 fix: isolate tests from production database
Tests that imported wled_controller.main at module level caused the real
production database (data/ledgrab.db) to be opened before test fixtures
could patch the config. This led to silent data loss.

Patch the global config singleton at conftest module level (before any
test imports main.py) to redirect all DB access to a temp directory.
2026-04-01 19:01:56 +03:00
alexei.dolgolyov 6b0e4e5539 feat(processed-audio-sources): phase 8 - frontend design consistency review
Fix audio source modal error class (modal-error), use Modal.showError(),
reorder audio source card description, remove redundant APT filter count
badge, clean up unused imports in audio-sources.ts.
2026-03-31 23:11:17 +03:00
alexei.dolgolyov ce1f4847f3 feat(processed-audio-sources): phase 7 - testing and polish
Fix test_list_filters test (filter_id field name mismatch).
Add tests for audio filters, template store, and source store.
All 678 tests pass, ruff clean, tsc clean, esbuild clean.
No dead code remaining from old source types.
2026-03-31 22:50:02 +03:00
alexei.dolgolyov 1ce0dc6c61 feat(processed-audio-sources): phase 6 - frontend source type cleanup
Rewrite audio source editor modal for capture/processed types only.
Remove old multichannel/mono/band_extract HTML sections and i18n keys.
Clean up legacy DOM section null-checks in audio-sources.ts.
2026-03-31 19:40:37 +03:00
alexei.dolgolyov 553463935e feat(processed-audio-sources): phase 5 - frontend audio processing templates
Add Audio Processing Templates management UI to Streams tab:
- Template editor modal with filter list via FilterListManager
- CardSection with reconciliation for template cards
- DataCache instances for templates and audio filter defs
- Audio filter icon mappings in filter-list.ts
- i18n keys in en/ru/zh locales
2026-03-31 19:32:17 +03:00
alexei.dolgolyov ab43578049 feat(processed-audio-sources): phase 4 - runtime filter integration
Add AudioFilterPipeline for chained filter execution on AudioAnalysis.
Wire filter pipelines into AudioColorStripStream, AudioValueStream,
and WebSocket test endpoint. Add hot-update support via
ProcessorManager.refresh_audio_filter_pipelines(). Thread
AudioProcessingTemplateStore through dependency injection hierarchy.
2026-03-31 19:15:29 +03:00
alexei.dolgolyov 353c090b42 feat(processed-audio-sources): phase 3 - processed audio source model
Replace MultichannelAudioSource with CaptureAudioSource, add
ProcessedAudioSource (audio_source_id + audio_processing_template_id),
remove MonoAudioSource and BandExtractAudioSource entirely.
Update store resolution to walk processed chains collecting template IDs.
Update all API schemas, routes, and frontend references.
2026-03-31 19:01:46 +03:00
alexei.dolgolyov eb94066386 feat(processed-audio-sources): phase 2 - implement 11 audio filters
Add all audio filters that transform AudioAnalysis data:
- Channel Extract, Band Extract (migration from old source types)
- Peak Hold, Gain, Noise Gate, Envelope Follower
- Spectral Smoothing, Compressor, Inverter, Beat Gate, Delay
All registered via AudioFilterRegistry with option schemas.
2026-03-31 18:43:36 +03:00
alexei.dolgolyov 86a9d344e6 feat(processed-audio-sources): phase 1 - audio filter framework
Add the foundation for audio processing filters, mirroring the existing
picture filter/postprocessing template system:
- AudioFilter base class, AudioFilterRegistry, AudioFilterOptionDef
- AudioProcessingTemplate dataclass + SQLite-backed store
- audio_filter_template meta-filter with recursive resolution
- Full REST API: CRUD templates + filter registry discovery
- Dependency injection wired in dependencies.py and main.py
2026-03-31 17:35:39 +02:00
alexei.dolgolyov c59107c7c7 feat: refactor MQTT from global config to multi-instance entity model
Lint & Test / test (push) Successful in 1m32s
MQTT broker connections are now managed as entities (like HA sources)
instead of a single global config. Each MQTTSource gets its own
runtime with auto-reconnect, ref-counted via MQTTManager.

Backend:
- MQTTSource dataclass + MQTTSourceStore (SQLite)
- MQTTRuntime (per-broker connection, refactored from MQTTService)
- MQTTManager (ref-counted pool, same pattern as HAManager)
- CRUD API at /api/v1/mqtt/sources + test + status endpoints
- MQTTRule gains mqtt_source_id field for source selection
- Automation engine acquires/releases MQTT runtimes automatically
- Legacy MQTTService kept for backward compat during transition

Frontend:
- MQTT source cards in Streams > Integrations tab
- Create/edit modal with test button
- Dashboard integration cards with health-dot indicators
- Removed MQTT tab from settings modal
2026-03-31 18:02:19 +03:00
alexei.dolgolyov e7c9a568dc feat: HA source cards use health-dot indicators
Lint & Test / test (push) Successful in 1m39s
Replace unstyled status-dot classes with the existing health-dot
pattern (green/red glow) matching LED device cards. Tooltip shows
connection status and entity count.
2026-03-31 16:05:01 +03:00
alexei.dolgolyov b36ddfd395 Merge branch 'feature/game-integration'
Lint & Test / test (push) Successful in 1m49s
Game integration system: receive real-time events from games
(CS2, Dota 2, LoL, etc.) and drive LED effects via color strip
streams and value source bindings.
2026-03-31 14:23:38 +03:00
alexei.dolgolyov 492bdb95e3 feat: game integration system
Receive real-time events from games (CS2, Dota 2, LoL, etc.) and drive
LED effects through the existing color strip and value source pipelines.

Core:
- GameEventBus (thread-safe pub/sub) with standardized 23-type event vocabulary
- GameAdapter ABC + AdapterRegistry + MappingAdapter (YAML-driven)
- Built-in adapters: CS2 GSI, Dota 2 GSI, LoL Live Client, Generic Webhook
- Community YAML adapters: Minecraft, Valorant, Rocket League
- GameEventColorStripStream with 5 effects (flash/pulse/sweep/color_shift/breathing)
- GameEventValueSource with EMA smoothing and timeout
- 4 built-in effect presets (FPS Combat, MOBA Health, Racing, Generic Alert)
- Auto-setup for Valve GSI games (Steam path detection, cfg file writing)
- Demo capture engine exposed to non-demo mode

Frontend:
- Game tab in Streams tree navigation with integration cards
- Game integration editor modal with adapter picker, config fields, event mappings
- game_event source type in CSS and ValueSource editors
- Setup instructions overlay (markdown rendered)
- Live event monitor and connection test

API:
- Full CRUD for game integrations
- Event ingestion endpoint (adapter-level auth)
- Adapter metadata, presets, auto-setup, status/diagnostics endpoints
2026-03-31 13:17:52 +03:00
alexei.dolgolyov b6713be390 feat: system_metrics value source type
Lint & Test / test (push) Successful in 1m32s
New value source that monitors host hardware via psutil/pynvml:
cpu_load, cpu_temp, gpu_load, gpu_temp, ram_usage, disk_usage,
network_rx, network_tx, battery_level, fan_speed.

Each metric normalizes to 0.0-1.0 with configurable ranges, poll
interval, EMA smoothing, and sensor_label for multi-sensor systems.
Conditional editor fields show/hide based on selected metric.

Also fixes: WS test crash when raw_value streams lack _min_ha attr,
toast timer overlap on rapid calls, SW cache bump to v34.
2026-03-30 18:22:58 +03:00
alexei.dolgolyov db5008aaeb feat: system theme option + fix toast timer overlap
Lint & Test / test (push) Successful in 1m27s
Add third theme mode (system) that follows OS prefers-color-scheme.
Theme button cycles dark → light → system with monitor icon.
Listens for OS preference changes in real time when in system mode.

Fix showToast clearing previous timer so rapid calls don't cause
the toast to disappear early.
2026-03-30 13:55:38 +03:00
alexei.dolgolyov 4b7a8d75f4 feat: value source card crosslinks + gradient_map test shows input value
Lint & Test / test (push) Successful in 1m23s
Add navigateToCard crosslinks for ha_entity (→ HA source), gradient_map
(→ input value source + gradient entity), and css_extract (→ color strip).
Gradient map test now charts the input interpolation factor instead of
output luminance, making the 0–1 chart meaningful.
2026-03-30 03:23:05 +03:00
alexei.dolgolyov f6c25cd15f feat: color value source test visualization
Lint & Test / test (push) Successful in 1m6s
WS now sends color RGB data for color-type streams. Test modal renders
a color history swatch strip below the chart, colors the chart line and
fill area with the current output color, and shows rgb() in the stats.
Works for static_color, animated_color, and adaptive_time_color sources.
2026-03-30 03:12:57 +03:00
alexei.dolgolyov 0a8737157c feat: HA value source test — raw value axis + behavior IconSelect
Lint & Test / test (push) Successful in 1m9s
- WS sends raw_value and raw_range for HA entity streams
- Canvas draws right-side Y-axis labels: configured min/max range
  and current raw HA value (green, positioned at correct Y)
- Replace plain <select> for scene behavior with IconSelect (moon/sun)
2026-03-30 03:06:44 +03:00
alexei.dolgolyov 11d5d6b5e1 fix: device card header layout — URL badge overflow and hide button gap
Lint & Test / test (push) Successful in 1m24s
Move URL badge from card-title to card-subtitle row to prevent
overlap with top-right action buttons. Widen card-header padding-right
to 60px for 2-button clearance. Reorder hide button to first position
in top-actions so power and trash stay visually adjacent.
2026-03-30 02:05:45 +03:00
alexei.dolgolyov 384362ccf1 feat: new value source types (HA entity, gradient map, strip extract) + UI fixes
Lint & Test / test (push) Successful in 1m27s
New value source types:
- ha_entity: reads numeric values from HA entity state/attribute, normalizes
  via min/max range, applies EMA smoothing. EntitySelect for HA connection
  and entity selection with live entity list fetching.
- gradient_map: maps a float value source (0-1) through a gradient entity.
  EntitySelect for both input source and gradient with inline previews.
- css_extract: extracts single color by averaging LED range from a color
  strip source. EntitySelect for source selection.

Value source type picker:
- Filter tabs (All / Numeric / Color) above the icon grid
- showTypePicker extended with filterTabs + onFilterChange support

Palette selectors converted to EntitySelect:
- Effect palette, gradient preset, and audio palette selectors now use
  command-palette style EntitySelect with gradient strip previews

Tab indicator fixes:
- Icon now updates on tab switch (was passing no args to updateTabIndicator)
- Visible with any background effect active, not just Noise Field
- Noise Field is the default background effect for new users

Dashboard section collapse fix:
- Split header into clickable toggle (chevron+label) and non-clickable
  actions area — buttons no longer trigger collapse/expand

Discriminated union fix (422 errors):
- source_type/target_type now always included in update payloads for:
  CSS editor, LED target, HA light target, simple calibration,
  advanced calibration
2026-03-29 20:38:22 +03:00
alexei.dolgolyov ea812bb4d5 feat: check if port is busy before starting the server
Lint & Test / test (push) Successful in 1m16s
2026-03-29 14:21:35 +03:00
alexei.dolgolyov a9e6e8cb82 fix: KC color strip test preview — use LiveStreamManager instead of raw engine
Lint & Test / test (push) Successful in 1m28s
BetterCam (DXGI) only supports one capture session per display, so creating
a second engine for the KC test produced no frames when a target was already
running. Now uses LiveStreamManager.acquire() which ref-counts and reuses
the running capture.

Also removed double postprocessing — ProcessedLiveStream already applies
filters, so re-applying them in the KC test halved the resolution (960x400
→ 240x100).
2026-03-29 14:11:01 +03:00
alexei.dolgolyov 78ce6c84d7 fix: composite layer opacity/brightness widgets + CSS layout
Lint & Test / test (push) Successful in 1m7s
- Fix opacity widget empty space (CSS selector .composite-layer-opacity → .composite-layer-opacity-container)
- Replace brightness select dropdown with BindableScalarWidget (slider + VS toggle)
- Legacy brightness_source_id auto-converted to BindableFloat on load
- Add .composite-layer-brightness-container CSS rule
2026-03-29 00:42:42 +03:00
alexei.dolgolyov 8a17bb5caa feat: BindableFloat — universal value source binding for all scalar properties
Lint & Test / test (push) Successful in 1m20s
Introduce BindableFloat abstraction that allows any numeric property to be
either a static value or dynamically driven by a ValueSource. Backward-compatible
serialization: plain float when unbound, {value, source_id} dict when bound.

Backend:
- storage/bindable.py — BindableFloat dataclass + bfloat() helper
- 25+ scalar properties converted across all entity types
- Runtime VS acquisition in ColorStripStreamManager for CSS bindings
- All stream hot loops use self.resolve() for live values
- KeyColorsColorStripStream now inherits ColorStripStream

Frontend:
- BindableScalarWidget (slider + VS picker toggle) for all editors
- TypeScript BindableFloat type + helpers
- Graph editor edges for all bindable properties
- Audio source channel IconSelect grid

Fixes: daylight longitude, candlelight wind_strength/candle_type from_dict
2026-03-29 00:33:24 +03:00
alexei.dolgolyov 5f70302263 feat: use custom app icon for shortcuts and installer
Lint & Test / test (push) Successful in 1m15s
- Generate icon.ico from icon-512.png (16-256px sizes)
- Set MUI_ICON/MUI_UNICON for installer wizard
- Point all shortcuts to icon.ico instead of pythonw.exe
- Add DisplayIcon registry entry for Add/Remove Programs
2026-03-28 18:41:12 +03:00
alexei.dolgolyov 40751fecb7 feat: HA light target live color preview — per-entity swatches via WebSocket
Lint & Test / test (push) Successful in 1m24s
- Cache per-entity colors in HALightTargetProcessor._update_lights()
- Broadcast colors_update to WS clients at target's update_rate
- WS endpoint: /api/v1/output-targets/{target_id}/ha-light/ws
- Frontend: connect WS when target runs, update swatch colors live
- Card shows colored boxes per mapped entity with entity name labels
2026-03-28 18:28:16 +03:00
alexei.dolgolyov 381ee75371 fix: HA light target — brightness source, transition=0, dashboard type label
Lint & Test / test (push) Successful in 1m13s
- Add brightness_value_source_id to HALightOutputTarget model, to_dict,
  from_dict, update_fields, register_with_manager, API response
- Wire value stream in HALightTargetProcessor: acquire/release on
  start/stop, multiply brightness in _update_lights loop
- Fix transition=0 not saving (parseFloat("0") || 0.5 was falsy)
- Fix dashboard showing "Key Colors" for HA targets — now "Home Assistant"
- Fix dashboard FPS showing 0/2 — HA targets show target/target
- Add CSS source subtitle to HA target dashboard cards
2026-03-28 16:03:06 +03:00
alexei.dolgolyov 3e6760f726 refactor: key colors targets → CSS source type, HA target improvements
Lint & Test / test (push) Successful in 1m26s
Key Colors refactor:
- New `key_colors` CSS source type with inline rectangles
- KeyColorsColorStripStream: extracts N colors from screen regions
- CSS editor: EntitySelect for picture source, IconSelect for color mode
- Configure Regions button on card opens pattern canvas editor
- Live WS preview at 5 FPS with rectangle overlay + color swatches
- Removed KC target type, pattern template entity, and related API routes
- Removed KC/pattern template sections from Targets tab

HA light target improvements:
- Update rate, transition, mappings, brightness VS now editable via PUT
- Card crosslinks for HA source, CSS source, brightness VS
- HA connection status icon, text metrics (Hz, uptime)
- Brightness value source selector in editor
2026-03-28 15:28:22 +03:00
alexei.dolgolyov 89d1b13854 fix: rename HA Lights → Home Assistant, HA Light Targets → Light Targets
Lint & Test / test (push) Successful in 1m38s
2026-03-28 11:30:40 +03:00
alexei.dolgolyov 324a308805 feat: entity picker for HA light mapping — searchable EntitySelect for light entities
Lint & Test / test (push) Failing after 11m19s
Replaces plain text input with EntitySelect dropdown that fetches
available light.* entities from the selected HA source. Changing the
HA source refreshes the entity list across all mapping rows.
2026-03-28 00:35:42 +03:00
alexei.dolgolyov cb9289f01f feat: HA light output targets — cast LED colors to Home Assistant lights
Lint & Test / test (push) Has been cancelled
New output target type `ha_light` that sends averaged LED colors to HA
light entities via WebSocket service calls (light.turn_on/turn_off):

Backend:
- HARuntime.call_service(): fire-and-forget WS service calls
- HALightOutputTarget: data model with light mappings, update rate, transition
- HALightTargetProcessor: processing loop with delta detection, rate limiting
- ProcessorManager.add_ha_light_target(): registration
- API schemas/routes updated for ha_light target type

Frontend:
- HA Light Targets section in Targets tab tree nav
- Modal editor: HA source picker, CSS source picker, light entity mappings
- Target cards with start/stop/clone/edit actions
- i18n keys for all new UI strings
2026-03-28 00:08:49 +03:00
alexei.dolgolyov fb98e6e2b8 ci: add manual build workflow for testing artifacts
Lint & Test / test (push) Has been cancelled
workflow_dispatch-triggered build.yml that produces Windows
installer/portable and Linux tarball as CI artifacts without
creating a release. Trigger from Gitea UI → Actions → Run.
2026-03-27 23:41:22 +03:00
alexei.dolgolyov 3c2efd5e4a refactor: move Weather and Home Assistant sources to Integrations tree group
Separates external service connections from utility entities in the
Streams tree navigation for clearer organization.
2026-03-27 23:21:40 +03:00
alexei.dolgolyov 2153dde4b7 feat: Home Assistant integration — WebSocket connection, automation conditions, UI
Add full Home Assistant integration via WebSocket API:
- HARuntime: persistent WebSocket client with auth, auto-reconnect, entity state cache
- HAManager: ref-counted runtime pool (like WeatherManager)
- HomeAssistantCondition: new automation trigger type matching entity states
- REST API: CRUD for HA sources + /test, /entities, /status endpoints
- /api/v1/system/integrations-status: combined MQTT + HA dashboard indicators
- Frontend: HA Sources tab in Streams, condition type in automation editor
- Modal editor with host, token, SSL, entity filters
- websockets>=13.0 dependency added
2026-03-27 22:42:48 +03:00
alexei.dolgolyov f3d07fc47f feat: donation banner, About tab, settings UI improvements
Lint & Test / test (push) Has been cancelled
- Dismissible donation/open-source banner after 3+ sessions (30-day snooze)
- New About tab in Settings: version, repo link, license info
- Centralize project URLs (REPO_URL, DONATE_URL) in __init__.py, served via /health
- Center settings tab bar, reduce tab padding for 6-tab fit
- External URL save button: icon button instead of full-width text button
- Remove redundant settings footer close button
- Footer "Source Code" link replaced with "About" opening settings
- i18n keys for en/ru/zh
2026-03-27 21:09:34 +03:00
alexei.dolgolyov f61a0206d4 feat: custom file drop zone for asset upload modal; fix review issues
Lint & Test / test (push) Successful in 1m29s
Replace plain <input type="file"> with a styled drag-and-drop zone
featuring hover/drag states, file info display, and remove button.

Fix 10 review issues: add 401 handling to upload, remove non-null
assertions, add missing i18n keys (common.remove, error.play_failed,
error.download_failed), normalize close button glyphs to &#x2715;,
i18n the dropzone aria-label, replace silent error catches with toast
notifications, use DataTransfer for cross-browser file assignment.
2026-03-26 21:43:08 +03:00
alexei.dolgolyov f345687600 chore: remove python3.11 version pin from pre-commit config
Lint & Test / test (push) Successful in 1m6s
2026-03-26 20:41:34 +03:00
alexei.dolgolyov e2e1107df7 feat: asset-based image/video sources, notification sounds, UI improvements
Lint & Test / test (push) Has been cancelled
- Replace URL-based image_source/url fields with image_asset_id/video_asset_id
  on StaticImagePictureSource and VideoCaptureSource (clean break, no migration)
- Resolve asset IDs to file paths at runtime via AssetStore.get_file_path()
- Add EntitySelect asset pickers for image/video in stream editor modal
- Add notification sound configuration (global sound + per-app overrides)
- Unify per-app color and sound overrides into single "Per-App Overrides" section
- Persist notification history between server restarts
- Add asset management system (upload, edit, delete, soft-delete)
- Replace emoji buttons with SVG icons throughout UI
- Various backend improvements: SQLite stores, auth, backup, MQTT, webhooks
2026-03-26 20:40:25 +03:00
alexei.dolgolyov c0853ce184 fix: improve command palette actions and automation condition button
Lint & Test / test (push) Successful in 1m16s
- Action items (start/stop, enable/disable) no longer close the palette
- Action items toggle state after success (Start→Stop, Enable→Disable)
- Toast z-index raised above command palette backdrop (3000→3500)
- Automation condition remove button uses ICON_TRASH SVG instead of ✕
2026-03-26 02:21:52 +03:00
alexei.dolgolyov 3e0bf8538c feat: add api_input LED interpolation; fix LED preview, FPS charts, dashboard layout
Lint & Test / test (push) Successful in 1m26s
API Input:
- Add interpolation mode (linear/nearest/none) for LED count mismatch
  between incoming data and device LED count
- New IconSelect in editor, i18n for en/ru/zh
- Mark crossfade as won't-do (client owns temporal transitions)
- Mark last-write-wins as already implemented

LED Preview:
- Fix zone-mode preview parsing composite wire format (0xFE header
  bytes were rendered as color data, garbling multi-zone previews)
- Fix _restoreLedPreviewState to handle zone-mode panels

FPS Charts:
- Seed target card charts from server metrics-history on first load
- Add fetchMetricsHistory() with 5s TTL cache shared across
  dashboard, targets, perf-charts, and graph-editor
- Fix chart padding: pass maxSamples per caller (120 for dashboard,
  30 for target cards) instead of hardcoded 120
- Fix dashboard chart empty on tab switch (always fetch server history)
- Left-pad with nulls for consistent chart width across targets

Dashboard:
- Fix metrics row alignment (grid layout with fixed column widths)
- Fix FPS label overflow into uptime column
2026-03-26 02:06:49 +03:00
alexei.dolgolyov be4c98b543 fix: show template name instead of ID in filter list and card badges
Lint & Test / test (push) Successful in 1m7s
Collapsed filter cards in the modal showed raw template IDs (e.g.
pp_cb72e227) instead of resolving select options to their labels.
Card filter chain badges now include the referenced template name.
2026-03-25 23:56:40 +03:00
alexei.dolgolyov dca2d212b1 fix: clip graph node title and subtitle to prevent overflow
Long entity names overflowed past the icon area on graph cards.
Added SVG clipPath to constrain text within the node bounds.
2026-03-25 23:56:30 +03:00
alexei.dolgolyov 53986f8d95 fix: replace emoji with SVG icons on weather and daylight cards
Weather card used  and 🌡 emoji, daylight card used 🕐 and .
Replaced with ICON_FAST_FORWARD, ICON_THERMOMETER, and ICON_CLOCK.
Added thermometer icon path.
2026-03-25 23:56:21 +03:00
alexei.dolgolyov a4a9f6f77f fix: send gradient_id instead of palette in effect transient preview
Lint & Test / test (push) Successful in 1m19s
The preview config was sending `palette` which defaults to "fire" on
the server, ignoring the user's selected gradient. Also removed the
dead fallback notification block and stale custom_palette check.
2026-03-25 23:43:33 +03:00
alexei.dolgolyov 9fcfdb8570 ci: use sparse checkout for release notes in release workflow
Only fetch RELEASE_NOTES.md instead of full repo checkout, and
simplify the file detection to a direct path check.
2026-03-25 23:43:31 +03:00
alexei.dolgolyov 85b886abf8 chore: update release notes for v0.2.2
Build Release / create-release (push) Successful in 4s
Build Release / build-linux (push) Successful in 2m27s
Lint & Test / test (push) Successful in 3m20s
Build Release / build-docker (push) Successful in 3m13s
Build Release / build-windows (push) Successful in 3m59s
2026-03-25 22:43:53 +03:00
alexei.dolgolyov a5e7a4e52f feat: add 4 built-in gradients, searchable gradient picker, cleaner modal titles
Lint & Test / test (push) Successful in 1m29s
- Add warm, cool, neon, pastel built-in gradients (promoted from frontend presets)
- Change gradient seeding to add missing built-ins on every startup (not just first run)
- Add searchable option to IconSelect component for filtering by name
- Enable search on gradient, effect palette, and audio palette pickers
- Simplify modal titles: "Add Gradient" / "Edit Gradient" instead of "Add Color Strip Source: Gradient"
- Update INSTALLATION.md and .env.example
2026-03-25 22:38:24 +03:00
alexei.dolgolyov 82ce2a7e2b chore: update release notes for v0.2.1-alpha.3
Build Release / create-release (push) Successful in 3s
Build Release / build-docker (push) Successful in 2m20s
Lint & Test / test (push) Successful in 2m47s
Build Release / build-linux (push) Successful in 6m31s
Build Release / build-windows (push) Successful in 7m25s
2026-03-25 21:36:19 +03:00
alexei.dolgolyov 2eeae4a7c1 feat: add release notes overlay with Markdown rendering
- Replace truncated plaintext release notes with full-screen overlay
  rendered via `marked` library
- Server reconnection does a hard page reload instead of custom event
2026-03-25 21:34:59 +03:00
alexei.dolgolyov f4da47ca2b fix: rename GITEA_TOKEN to DEPLOY_TOKEN in CI workflow
Lint & Test / test (push) Successful in 2m39s
Build Release / create-release (push) Successful in 2s
Build Release / build-linux (push) Successful in 2m11s
Build Release / build-docker (push) Successful in 2m52s
Build Release / build-windows (push) Successful in 3m30s
GITEA_TOKEN is a reserved name in Gitea — the UI and API reject it
when creating action secrets.
2026-03-25 14:41:02 +03:00
alexei.dolgolyov 7939322a7f feat: reduce build size — replace Pillow with cv2, refactor build scripts
Build Release / create-release (push) Successful in 1s
Build Release / build-docker (push) Successful in 42s
Lint & Test / test (push) Successful in 2m50s
Build Release / build-windows (push) Successful in 3m27s
Build Release / build-linux (push) Successful in 1m59s
- Create utils/image_codec.py with cv2-based image helpers
- Replace PIL usage across all routes, filters, and engines with cv2
- Move Pillow from core deps to [tray] optional in pyproject.toml
- Extract shared build logic into build-common.sh (detect_version, cleanup, etc.)
- Strip unused NumPy/PIL/zeroconf/debug files in build scripts
2026-03-25 14:18:16 +03:00
795 changed files with 107764 additions and 40276 deletions
+246
View File
@@ -0,0 +1,246 @@
name: Build Android APK
on:
# Release tags only — building the ~100 MB APK on every master push
# burned Gitea runner minutes without producing a useful artifact.
# Use workflow_dispatch for on-demand dev builds.
push:
tags: ['v*']
workflow_dispatch:
inputs:
version:
description: 'Version label (e.g. dev, 0.3.0-test)'
required: false
default: 'dev'
jobs:
build-android:
runs-on: ubuntu-latest
env:
JAVA_VERSION: '17'
PYTHON_VERSION: '3.11'
ANDROID_CMDLINE_TOOLS_VERSION: '11076708'
ANDROID_SDK_PLATFORM: 'android-34'
ANDROID_BUILD_TOOLS: '34.0.0'
ANDROID_NDK_VERSION: '26.1.10909125'
# Surfaced at job level (not step level) so the `if: env.X != ''`
# check on the Decode step actually sees it — step-level env is
# NOT available in that step's own `if:` expression, which
# silently skipped the decode and produced debug-signed release
# APKs until it was noticed.
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve build label
id: label
run: |
REF="${{ gitea.ref_name }}"
if echo "$REF" | grep -qE '^v[0-9]'; then
LABEL="${REF#v}"
IS_RELEASE="true"
elif [ -n "${{ inputs.version }}" ]; then
LABEL="${{ inputs.version }}"
IS_RELEASE="false"
else
LABEL="dev-${{ gitea.sha }}"
IS_RELEASE="false"
fi
LABEL="${LABEL:0:40}"
echo "label=$LABEL" >> "$GITHUB_OUTPUT"
echo "is_release=$IS_RELEASE" >> "$GITHUB_OUTPUT"
echo "Build label: $LABEL (release=$IS_RELEASE)"
- name: Setup JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ env.JAVA_VERSION }}
- name: Setup Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Android SDK + NDK
run: |
set -euo pipefail
SDK_ROOT="$HOME/android-sdk"
mkdir -p "$SDK_ROOT/cmdline-tools"
cd "$SDK_ROOT/cmdline-tools"
curl -sSL --retry 3 \
-o cmdline-tools.zip \
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip"
unzip -q cmdline-tools.zip
rm cmdline-tools.zip
mv cmdline-tools latest
export ANDROID_SDK_ROOT="$SDK_ROOT"
export ANDROID_HOME="$SDK_ROOT"
SDKMANAGER="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager"
yes | "$SDKMANAGER" --licenses > /dev/null 2>&1 || true
"$SDKMANAGER" --install \
"platform-tools" \
"platforms;${ANDROID_SDK_PLATFORM}" \
"build-tools;${ANDROID_BUILD_TOOLS}" \
"ndk;${ANDROID_NDK_VERSION}" > /dev/null
echo "ANDROID_SDK_ROOT=$SDK_ROOT" >> "$GITHUB_ENV"
echo "ANDROID_HOME=$SDK_ROOT" >> "$GITHUB_ENV"
echo "ANDROID_NDK_HOME=$SDK_ROOT/ndk/${ANDROID_NDK_VERSION}" >> "$GITHUB_ENV"
echo "$SDK_ROOT/platform-tools" >> "$GITHUB_PATH"
echo "$SDK_ROOT/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
- name: Create local.properties
run: |
echo "sdk.dir=$ANDROID_SDK_ROOT" > android/local.properties
- name: Link Python source (junction equivalent)
run: |
set -euo pipefail
# Chaquopy reads Python modules from android/app/src/main/python/
# On Windows dev machines this is a directory junction; on Linux
# CI use a symlink. The parent dir is .gitignore'd (the junction
# target is the real content), so we have to create it first.
mkdir -p android/app/src/main/python
ln -sfn "$(pwd)/server/src/ledgrab" android/app/src/main/python/ledgrab
ls -la android/app/src/main/python/
# Sanity check — readlink resolves the link and the directory exists.
test -d android/app/src/main/python/ledgrab
- name: Decode signing keystore
id: keystore
if: env.ANDROID_KEYSTORE_BASE64 != ''
run: |
set -euo pipefail
mkdir -p android/keystore
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/keystore/release.jks
echo "path=$(pwd)/android/keystore/release.jks" >> "$GITHUB_OUTPUT"
echo "present=true" >> "$GITHUB_OUTPUT"
- name: Guard release tag against missing keystore
# Release tags MUST produce a release-signed APK, otherwise existing
# installs can't upgrade (signature mismatch). Fail loudly instead
# of silently falling back to the debug signing config.
if: ${{ steps.label.outputs.is_release == 'true' && steps.keystore.outputs.present != 'true' }}
run: |
echo "::error::Release tag ${{ gitea.ref_name }} requires ANDROID_KEYSTORE_BASE64 (plus KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD) to be configured in Gitea → Settings → Secrets."
exit 1
- name: Build APK
working-directory: android
env:
ANDROID_KEYSTORE_PATH: ${{ steps.keystore.outputs.path }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
chmod +x gradlew
if [ "${{ steps.keystore.outputs.present }}" = "true" ] && [ "${{ steps.label.outputs.is_release }}" = "true" ]; then
echo "Building signed release APK"
./gradlew --no-daemon assembleRelease
else
echo "Building debug APK (no signing keystore available or not a release tag)"
./gradlew --no-daemon assembleDebug
fi
- name: Locate and rename APK
id: apk
run: |
set -euo pipefail
SRC=$(ls android/app/build/outputs/apk/release/*.apk 2>/dev/null | head -1 || true)
if [ -z "$SRC" ]; then
SRC=$(ls android/app/build/outputs/apk/debug/*.apk | head -1)
VARIANT="debug"
else
VARIANT="release"
fi
DEST="build/LedGrab-${{ steps.label.outputs.label }}-android-${VARIANT}.apk"
mkdir -p build
cp "$SRC" "$DEST"
echo "path=$DEST" >> "$GITHUB_OUTPUT"
echo "name=$(basename "$DEST")" >> "$GITHUB_OUTPUT"
ls -lh "$DEST"
- name: Upload APK artifact
uses: actions/upload-artifact@v3
with:
name: LedGrab-${{ steps.label.outputs.label }}-android
path: ${{ steps.apk.outputs.path }}
retention-days: 90
- name: Attach APK to Gitea release (upsert)
if: ${{ steps.label.outputs.is_release == 'true' }}
env:
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ gitea.ref_name }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
APK_PATH="${{ steps.apk.outputs.path }}"
APK_NAME="${{ steps.apk.outputs.name }}"
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "::error::DEPLOY_TOKEN secret not configured — cannot attach APK"
exit 1
fi
# Upsert: look up release by tag. If it exists, reuse it; if 404,
# create one. Makes the Android workflow self-sufficient — no
# ordering dependency on release.yml's create-release job.
HTTP=$(curl -s -o /tmp/release.json -w "%{http_code}" \
"$BASE_URL/releases/tags/$TAG" \
-H "Authorization: token $GITEA_TOKEN")
case "$HTTP" in
200)
RELEASE_ID=$(python3 -c "import json; print(json.load(open('/tmp/release.json'))['id'])")
echo "Found existing release id=$RELEASE_ID"
;;
404)
echo "No release for tag $TAG — creating one"
IS_PRE="false"
if echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
IS_PRE="true"
fi
CREATE_HTTP=$(curl -s -o /tmp/created.json -w "%{http_code}" \
-X POST "$BASE_URL/releases" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"LedGrab $TAG\",\"draft\":false,\"prerelease\":$IS_PRE}")
if [ "$CREATE_HTTP" != "201" ] && [ "$CREATE_HTTP" != "200" ]; then
echo "::error::Failed to create release (HTTP $CREATE_HTTP)"
cat /tmp/created.json
exit 1
fi
RELEASE_ID=$(python3 -c "import json; print(json.load(open('/tmp/created.json'))['id'])")
echo "Created release id=$RELEASE_ID"
;;
*)
echo "::error::Unexpected HTTP $HTTP when looking up release for tag $TAG"
cat /tmp/release.json
exit 1
;;
esac
# Replace existing asset if present (re-run safety).
EXISTING_ID=$(curl -fsS "$BASE_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $GITEA_TOKEN" \
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$APK_NAME'),''))")
if [ -n "$EXISTING_ID" ]; then
curl -fsS -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
-H "Authorization: token $GITEA_TOKEN"
echo "Replaced existing asset: $APK_NAME"
fi
# -f: exit non-zero on 4xx/5xx so a broken token fails the job
# loudly instead of the previous silent "Uploaded" lie.
curl -fsS -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$APK_NAME" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$APK_PATH"
echo "Uploaded: $APK_NAME"
+80
View File
@@ -0,0 +1,80 @@
name: Build Artifacts
on:
workflow_dispatch:
inputs:
version:
description: 'Version label (e.g. dev, 0.3.0-test)'
required: false
default: 'dev'
jobs:
build-windows:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends zip libportaudio2 nsis msitools
- name: Cross-build Windows distribution
run: |
chmod +x build/build-dist-windows.sh
./build/build-dist-windows.sh "v${{ inputs.version }}"
- uses: actions/upload-artifact@v3
with:
name: LedGrab-${{ inputs.version }}-win-x64
path: |
build/LedGrab-*.zip
build/LedGrab-*-setup.exe
retention-days: 90
build-linux:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libportaudio2
- name: Build Linux distribution
run: |
chmod +x build/build-dist.sh
./build/build-dist.sh "v${{ inputs.version }}"
- uses: actions/upload-artifact@v3
with:
name: LedGrab-${{ inputs.version }}-linux-x64
path: build/LedGrab-*.tar.gz
retention-days: 90
+104 -37
View File
@@ -4,18 +4,35 @@ on:
push:
tags:
- 'v*'
# Manual dispatch builds Windows/Linux/Docker artifacts without creating
# a Gitea release — for validating build scripts between real releases.
# Attach/push steps are gated on github.event_name == 'push'.
workflow_dispatch:
inputs:
version:
description: 'Version label for dispatch builds (artifacts only, no release)'
required: false
default: 'dev'
jobs:
# ── Create the release first (shared by all build jobs) ────
create-release:
# Skipped on workflow_dispatch — dispatch is for build validation only.
if: github.event_name == 'push'
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.create.outputs.release_id }}
steps:
- name: Fetch RELEASE_NOTES.md only
uses: actions/checkout@v4
with:
sparse-checkout: RELEASE_NOTES.md
sparse-checkout-cone-mode: false
- name: Create Gitea release
id: create
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
TAG="${{ gitea.ref_name }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
@@ -30,18 +47,36 @@ jobs:
REPO=$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')
DOCKER_IMAGE="${SERVER_HOST}/${REPO}"
if [ -f RELEASE_NOTES.md ]; then
export RELEASE_NOTES=$(cat RELEASE_NOTES.md)
echo "Found RELEASE_NOTES.md"
else
export RELEASE_NOTES=""
echo "No RELEASE_NOTES.md found"
fi
# Build release body via Python to avoid YAML escaping issues
BODY_JSON=$(python3 -c "
import json, sys
import json, sys, os, textwrap
tag = '$TAG'
image = '$DOCKER_IMAGE'
body = f'''## Downloads
release_notes = os.environ.get('RELEASE_NOTES', '')
sections = []
if release_notes.strip():
sections.append(release_notes.strip())
sections.append(textwrap.dedent(f'''
## Downloads
| Platform | File | Description |
|----------|------|-------------|
| Windows (installer) | \`LedGrab-{tag}-setup.exe\` | Install with Start Menu shortcut, optional autostart, uninstaller |
| Windows (portable) | \`LedGrab-{tag}-win-x64.zip\` | Unzip anywhere, run LedGrab.bat |
| Linux | \`LedGrab-{tag}-linux-x64.tar.gz\` | Extract, run ./run.sh |
| Android | \`LedGrab-{tag}-android-release.apk\` | Sideload on Android 7.0+ (API 24+) — TV boxes, Fire TV, phones, tablets. arm64-v8a / x86_64 / x86 |
| Docker | See below | docker pull + docker run |
After starting, open **http://localhost:8080** in your browser.
@@ -55,12 +90,12 @@ jobs:
### First-time setup
1. Change the default API key in config/default_config.yaml
2. Open http://localhost:8080 and discover your WLED devices
3. See INSTALLATION.md for detailed configuration
'''
import textwrap
print(json.dumps(textwrap.dedent(body).strip()))
1. Change the default API key in `config/default_config.yaml`.
2. Open http://localhost:8080 and add your LED devices.
3. See `INSTALLATION.md` for detailed configuration.
''').strip())
print(json.dumps('\n\n'.join(sections)))
")
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
@@ -88,6 +123,9 @@ jobs:
# ── Windows portable ZIP (cross-built from Linux) ─────────
build-windows:
needs: create-release
# `!cancelled()` lets this job run even when create-release was skipped
# (dispatch) or failed. The attach step itself is still push-gated.
if: ${{ !cancelled() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -112,8 +150,8 @@ jobs:
- name: Cross-build Windows distribution
run: |
chmod +x build-dist-windows.sh
./build-dist-windows.sh "${{ gitea.ref_name }}"
chmod +x build/build-dist-windows.sh
./build/build-dist-windows.sh "${{ gitea.ref_name }}"
- name: Upload build artifacts
uses: actions/upload-artifact@v3
@@ -125,8 +163,10 @@ jobs:
retention-days: 90
- name: Attach assets to release
# Push (tag) only — dispatch runs produce artifacts but no release.
if: github.event_name == 'push'
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
@@ -151,15 +191,26 @@ jobs:
echo "Uploaded: $NAME"
}
# Publish an asset plus its .sha256 sidecar. The in-app update
# service refuses to install without a published checksum, so
# every artifact needs its hash uploaded alongside.
upload_with_sha256() {
local FILE="$1"
upload_asset "$FILE"
(cd "$(dirname "$FILE")" && sha256sum "$(basename "$FILE")" > "$(basename "$FILE").sha256")
upload_asset "$FILE.sha256"
}
ZIP_FILE=$(ls build/LedGrab-*.zip | head -1)
[ -f "$ZIP_FILE" ] && upload_asset "$ZIP_FILE"
[ -f "$ZIP_FILE" ] && upload_with_sha256 "$ZIP_FILE"
SETUP_FILE=$(ls build/LedGrab-*-setup.exe 2>/dev/null | head -1)
[ -f "$SETUP_FILE" ] && upload_asset "$SETUP_FILE"
[ -f "$SETUP_FILE" ] && upload_with_sha256 "$SETUP_FILE"
# ── Linux tarball ──────────────────────────────────────────
build-linux:
needs: create-release
if: ${{ !cancelled() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -184,8 +235,8 @@ jobs:
- name: Build Linux distribution
run: |
chmod +x build-dist.sh
./build-dist.sh "${{ gitea.ref_name }}"
chmod +x build/build-dist.sh
./build/build-dist.sh "${{ gitea.ref_name }}"
- name: Upload build artifact
uses: actions/upload-artifact@v3
@@ -195,34 +246,44 @@ jobs:
retention-days: 90
- name: Attach tarball to release
if: github.event_name == 'push'
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
upload_asset() {
local FILE="$1"
local NAME
NAME=$(basename "$FILE")
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $GITEA_TOKEN" \
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$NAME'),''))" 2>/dev/null)
if [ -n "$EXISTING_ID" ]; then
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
-H "Authorization: token $GITEA_TOKEN"
echo "Replaced existing asset: $NAME"
fi
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$FILE"
echo "Uploaded: $NAME"
}
TAR_FILE=$(ls build/LedGrab-*.tar.gz | head -1)
TAR_NAME=$(basename "$TAR_FILE")
# Delete existing asset with same name to prevent duplicates on re-run
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $GITEA_TOKEN" \
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$TAR_NAME'),''))" 2>/dev/null)
if [ -n "$EXISTING_ID" ]; then
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
-H "Authorization: token $GITEA_TOKEN"
echo "Replaced existing asset: $TAR_NAME"
if [ -f "$TAR_FILE" ]; then
upload_asset "$TAR_FILE"
(cd "$(dirname "$TAR_FILE")" && sha256sum "$(basename "$TAR_FILE")" > "$(basename "$TAR_FILE").sha256")
upload_asset "$TAR_FILE.sha256"
fi
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$TAR_NAME" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$TAR_FILE"
echo "Uploaded: $TAR_NAME"
# ── Docker image ───────────────────────────────────────────
build-docker:
needs: create-release
if: ${{ !cancelled() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -246,14 +307,20 @@ jobs:
- name: Login to Gitea Container Registry
id: docker-login
# Dispatch runs don't need registry credentials — the build step
# verifies the Dockerfile locally and push is skipped.
if: github.event_name == 'push'
continue-on-error: true
run: |
echo "${{ secrets.GITEA_TOKEN }}" | docker login \
echo "${{ secrets.DEPLOY_TOKEN }}" | docker login \
"${{ steps.meta.outputs.server_host }}" \
-u "${{ gitea.actor }}" --password-stdin
- name: Build Docker image
if: steps.docker-login.outcome == 'success'
# Always build — dispatch uses this to validate the Dockerfile.
# On push, still gate on successful login so we don't build a
# tagged image that can't be pushed.
if: github.event_name != 'push' || steps.docker-login.outcome == 'success'
run: |
TAG="${{ gitea.ref_name }}"
REGISTRY="${{ steps.meta.outputs.registry }}"
@@ -272,7 +339,7 @@ jobs:
fi
- name: Push Docker image
if: steps.docker-login.outcome == 'success'
if: github.event_name == 'push' && steps.docker-login.outcome == 'success'
run: |
TAG="${{ gitea.ref_name }}"
REGISTRY="${{ steps.meta.outputs.registry }}"
+6
View File
@@ -5,9 +5,15 @@ on:
branches: [master]
pull_request:
branches: [master]
# Allow manual runs (e.g. to validate after a release commit was skipped).
workflow_dispatch:
jobs:
test:
# Skip release-publishing commits — version bumps don't affect lint/tests
# and the release.yml pipeline is already running. PRs and manual dispatch
# always run.
if: ${{ github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'chore: release') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
+27 -3
View File
@@ -4,7 +4,16 @@ __pycache__/
*$py.class
*.so
.Python
build/
# Build output artifacts (LedGrab/, *.zip, *.exe, *.tar.gz, cached downloads)
build/LedGrab/
build/*.zip
build/*.exe
build/*.tar.gz
build/*.msi
build/python-embed-*.zip
build/pip-wheels/
build/win-wheels/
build/tk-extract/
develop-eggs/
dist/
downloads/
@@ -16,6 +25,10 @@ parts/
sdist/
var/
wheels/
# …but keep pre-built Android wheels (pydantic-core cross-compiled for
# arm64-v8a / x86_64 / x86, required by the Chaquopy build)
!android/wheels/
!android/wheels/*
*.egg-info/
.installed.cfg
*.egg
@@ -49,8 +62,17 @@ htmlcov/
logs/
*.log.*
# Runtime data
data/
# Runtime data — anchor to repo root so nested package data dirs
# (server/src/ledgrab/data/prebuilt_sounds, game_adapters) are NOT ignored.
# An unanchored `data/` rule silently broke the v0.4.2 release by keeping
# shipped sound assets out of the CI tag checkout.
/data/
/server/data/
# Defensive: if the server is launched from server/src/ (uncommon path),
# its relative `data/` dir resolves to server/src/data/. Templates now
# live in SQLite, so any *.json that lands here is stale runtime export
# and must not be committed.
/server/src/data/
*.db
*.sqlite
*.json.bak
@@ -73,3 +95,5 @@ tmp/
# OS
Thumbs.db
.DS_Store
# Added by code-review-graph
.code-review-graph/
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"code-review-graph": {
"command": "uvx",
"args": [
"code-review-graph",
"serve"
],
"type": "stdio"
}
}
}
-1
View File
@@ -4,7 +4,6 @@ repos:
hooks:
- id: black
args: [--line-length=100, --target-version=py311]
language_version: python3.11
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
-118
View File
@@ -1,118 +0,0 @@
# Feature Brainstorm — LED Grab
## New Automation Conditions (Profiles)
Right now profiles only trigger on **app detection**. High-value additions:
- **Time-of-day / Schedule** — "warm tones after sunset, off at midnight." Schedule-based value sources pattern already exists
- **Display state** — detect monitor on/off/sleep, auto-stop targets when display is off
- **System idle** — dim or switch to ambient effect after N minutes of no input
- **Sunrise/sunset** — fetch local solar times, drive circadian color temperature shifts
- **Webhook/MQTT trigger** — let external systems activate profiles without HA integration
## New Output Targets
Currently: WLED, Adalight, AmbileD, DDP. Potential:
- **MQTT publish** — generic IoT output, any MQTT subscriber becomes a target
- **Art-Net / sACN (E1.31)** — stage/theatrical lighting protocols, DMX controllers
- **OpenRGB** — control PC peripherals (keyboard, mouse, RAM, fans) as ambient targets
- **HTTP webhook** — POST color data to arbitrary endpoints
- **Recording target** — save color streams to file for playback later
## New Color Strip Sources
- **Spotify / media player** — album art color extraction or tempo-synced effects
- **Weather** — pull conditions from API, map to palettes (blue=rain, orange=sun, white=snow)
- **Camera / webcam** — border-sampling from camera feed for video calls or room-reactive lighting
- **Script source** — user-written JS/Python snippets producing color arrays per frame
- **Notification reactive** — flash/pulse on OS notifications (optional app filter)
## Processing Pipeline Extensions
- **Palette quantization** — force output to match a user-defined palette
- **Zone grouping** — merge adjacent LEDs into logical groups sharing one averaged color
- **Color temperature filter** — warm/cool shift separate from hue shift (circadian/mood)
- **Noise gate** — suppress small color changes below threshold, preventing shimmer on static content
## Multi-Instance & Sync
- **Multi-room sync** — multiple instances with shared clock for synchronized effects
- **Multi-display unification** — treat 2-3 monitors as single virtual display for seamless ambilight
- **Leader/follower mode** — one target's output drives others with optional delay (cascade)
## UX & Dashboard
- **PWA / mobile layout** — mobile-first layout + "Add to Home Screen" manifest
- **Scene presets** — bundled source + filters + brightness as one-click presets ("Movie night", "Gaming")
- **Live preview on dashboard** — miniature screen with LED colors rendered around its border
- **Undo/redo for calibration** — reduce frustration in the fiddly calibration editor
- **Drag-and-drop filter ordering** — reorder postprocessing filter chains visually
## API & Integration
- **WebSocket event bus** — broadcast all state changes over a single WS channel
- **OBS integration** — detect active scene, switch profiles; or use OBS virtual camera as source
- **Plugin system** — formalize extension points into documented plugin API with hot-reload
## Creative / Fun
- **Effect sequencer** — timeline-based choreography of effects, colors, and transitions
- **Music BPM sync** — lock effect speed to detected BPM (beat detection already exists)
- **Color extraction from image** — upload photo, extract palette, use as gradient/cycle source
- **Transition effects** — crossfade, wipe, or dissolve between sources/profiles instead of instant cut
---
## Deep Dive: Notification Reactive Source
**Type:** New `ColorStripSource` (`source_type: "notification"`) — normally outputs transparent RGBA, flashes on notification events. Designed to be used as a layer in a **composite source** so it overlays on top of a persistent base (gradient, effect, screen capture, etc.).
### Trigger modes (both active simultaneously)
1. **OS listener (Windows)**`pywinrt` + `Windows.UI.Notifications.Management.UserNotificationListener`. Runs in background thread, pushes events to source via queue. Windows-only for now; macOS (`pyobjc` + `NSUserNotificationCenter`) and Linux (`dbus` + `org.freedesktop.Notifications`) deferred to future.
2. **Webhook**`POST /api/v1/notifications/{source_id}/fire` with optional body `{ "app": "MyApp", "color": "#FF0000" }`. Always available, cross-platform by nature.
### Source config
```yaml
os_listener: true # enable Windows notification listener
app_filter:
mode: whitelist|blacklist # which apps to react to
apps: [Discord, Slack, Telegram]
app_colors: # user-configured app → color mapping
Discord: "#5865F2"
Slack: "#4A154B"
Telegram: "#26A5E4"
default_color: "#FFFFFF" # fallback when app has no mapping
effect: flash|pulse|sweep # visual effect type
duration_ms: 1500 # effect duration
```
### Effect rendering
Source outputs RGBA color array per frame:
- **Idle**: all pixels `(0,0,0,0)` — composite passes through base layer
- **Flash**: instant full-color, linear fade to transparent over `duration_ms`
- **Pulse**: sine fade in/out over `duration_ms`
- **Sweep**: color travels across the strip like a wave
Each notification starts its own mini-timeline from trigger timestamp (not sync clock).
### Overlap handling
New notification while previous effect is active → restart timer with new color. No queuing.
### App color resolution
1. Webhook body `color` field (explicit override) → highest priority
2. `app_colors` mapping by app name
3. `default_color` fallback
---
## Top Picks (impact vs effort)
1. **Time-of-day + idle profile conditions** — builds on existing profile/condition architecture
2. **MQTT output target** — opens the door to an enormous IoT ecosystem
3. **Scene presets** — purely frontend, bundles existing features into one-click UX
+53 -1
View File
@@ -1,4 +1,4 @@
# Claude Instructions for WLED Screen Controller
# Claude Instructions for LedGrab
## Code Search
@@ -28,8 +28,21 @@ ast-index changed --base master # Show symbols changed in current bran
## Project Structure
- `/server` — Python FastAPI backend (see [server/CLAUDE.md](server/CLAUDE.md))
- `/android` — Android TV app (Kotlin shell + embedded Python via Chaquopy)
- `/contexts` — Context files for Claude (frontend conventions, graph editor, Chrome tools, server ops, demo mode)
## Android Dependency Sync (CRITICAL)
The Android app (`android/app/build.gradle.kts`) installs the server package with `--no-deps` and lists Android-compatible dependencies **explicitly** in the Chaquopy `pip {}` block. This is because `server/pyproject.toml` includes desktop-only packages (mss, psutil, sounddevice, etc.) that have no Android wheels.
**When adding a new dependency to `server/pyproject.toml`:**
1. If the package is **pure Python or has Chaquopy wheels** (check [Chaquopy PyPI](https://chaquo.com/pypi-13.1/)), also add it to `android/app/build.gradle.kts` in the `pip { install(...) }` block
2. If the package is **desktop-only** (native C/Rust extension without Android support), do NOT add it to `build.gradle.kts` — and guard its import with `try/except ImportError` in Python code
3. If unsure, check Chaquopy's package index first
**Incident context:** Chaquopy's pip runs on the build machine (Windows), not on Android. Platform markers like `sys_platform != 'linux'` evaluate against the BUILD host, not the target device. `pip install --exclude` does not exist. The only reliable way to exclude packages is to not list them.
## Context Files
| File | When to read |
@@ -91,3 +104,42 @@ Do NOT commit code that fails linting or tests. Fix the issues first.
- Follow existing code style and patterns
- Update documentation when changing behavior
- Never make commits or pushes without explicit user approval
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview` + `list_communities`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
|------|----------|
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context` | Need source snippets for review — token-efficient |
| `get_impact_radius` | Understanding blast radius of a change |
| `get_affected_flows` | Finding which execution paths are impacted |
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
| `get_architecture_overview` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes` for code review.
3. Use `get_affected_flows` to understand impact.
4. Use `query_graph` pattern="tests_for" to check coverage.
+4 -4
View File
@@ -9,8 +9,8 @@
## Development Setup
```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
cd wled-screen-controller-mixed/server
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd ledgrab/server
# Python environment
python -m venv venv
@@ -29,7 +29,7 @@ npm run build
cd server
export PYTHONPATH=$(pwd)/src # Linux/Mac
# set PYTHONPATH=%CD%\src # Windows
python -m wled_controller.main
python -m ledgrab.main
```
Open http://localhost:8080 to access the dashboard.
@@ -55,7 +55,7 @@ ruff check src/ tests/
## Frontend Changes
After modifying any file under `server/src/wled_controller/static/js/` or `static/css/`, rebuild the bundle:
After modifying any file under `server/src/ledgrab/static/js/` or `static/css/`, rebuild the bundle:
```bash
cd server
+30 -80
View File
@@ -1,15 +1,17 @@
# Installation Guide
Complete installation guide for LED Grab (WLED Screen Controller) server and Home Assistant integration.
Complete installation guide for the LedGrab server.
## Table of Contents
1. [Docker Installation (recommended)](#docker-installation)
2. [Manual Installation](#manual-installation)
3. [First-Time Setup](#first-time-setup)
4. [Home Assistant Integration](#home-assistant-integration)
5. [Configuration Reference](#configuration-reference)
6. [Troubleshooting](#troubleshooting)
4. [Configuration Reference](#configuration-reference)
5. [Troubleshooting](#troubleshooting)
> **Home Assistant integration** has moved to a separate repository:
> [ledgrab-haos-integration](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab-haos-integration)
---
@@ -20,8 +22,8 @@ The fastest way to get running. Requires [Docker](https://docs.docker.com/get-do
1. **Clone and start:**
```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
cd wled-screen-controller/server
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd ledgrab/server
docker compose up -d
```
@@ -54,7 +56,7 @@ cd server
docker build -t ledgrab .
docker run -d \
--name wled-screen-controller \
--name ledgrab \
-p 8080:8080 \
-v $(pwd)/data:/app/data \
-v $(pwd)/logs:/app/logs \
@@ -84,8 +86,8 @@ Screen capture from inside a container requires X11 access. Uncomment `network_m
1. **Clone the repository:**
```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
cd wled-screen-controller/server
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd ledgrab/server
```
2. **Build the frontend bundle:**
@@ -95,7 +97,7 @@ Screen capture from inside a container requires X11 access. Uncomment `network_m
npm run build
```
This compiles TypeScript and bundles JS/CSS into `src/wled_controller/static/dist/`.
This compiles TypeScript and bundles JS/CSS into `src/ledgrab/static/dist/`.
3. **Create a virtual environment:**
@@ -121,7 +123,6 @@ Screen capture from inside a container requires X11 access. Uncomment `network_m
Optional extras:
```bash
pip install ".[camera]" # Webcam capture via OpenCV
pip install ".[perf]" # DXCam, BetterCam, WGC (Windows only)
pip install ".[notifications]" # OS notification capture
pip install ".[dev]" # pytest, black, ruff (development)
@@ -132,11 +133,11 @@ Screen capture from inside a container requires X11 access. Uncomment `network_m
```bash
# Linux / macOS
export PYTHONPATH=$(pwd)/src
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
uvicorn ledgrab.main:app --host 0.0.0.0 --port 8080
# Windows (cmd)
set PYTHONPATH=%CD%\src
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
uvicorn ledgrab.main:app --host 0.0.0.0 --port 8080
```
6. **Verify:** open <http://localhost:8080> in your browser.
@@ -155,13 +156,13 @@ Option A -- edit the config file:
# server/config/default_config.yaml
auth:
api_keys:
main: "your-secure-key-here" # replace the dev key
dev: "your-secure-key-here" # replace the dev key
```
Option B -- set an environment variable:
```bash
export WLED_AUTH__API_KEYS__main="your-secure-key-here"
export LEDGRAB_AUTH__API_KEYS__dev="your-secure-key-here"
```
Generate a random key:
@@ -185,7 +186,7 @@ server:
Or via environment variable:
```bash
WLED_SERVER__CORS_ORIGINS='["http://localhost:8080","http://192.168.1.100:8080"]'
LEDGRAB_SERVER__CORS_ORIGINS='["http://localhost:8080","http://192.168.1.100:8080"]'
```
### Discover devices
@@ -194,57 +195,12 @@ Open the dashboard and go to the **Devices** tab. Click **Discover** to find WLE
---
## Home Assistant Integration
### Option 1: HACS (recommended)
1. Install [HACS](https://hacs.xyz/docs/setup/download) if you have not already.
2. Open HACS in Home Assistant.
3. Click the three-dot menu, then **Custom repositories**.
4. Add URL: `https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed`
5. Set category to **Integration** and click **Add**.
6. Search for "WLED Screen Controller" in HACS and click **Download**.
7. Restart Home Assistant.
8. Go to **Settings > Devices & Services > Add Integration** and search for "WLED Screen Controller".
9. Enter your server URL (e.g., `http://192.168.1.100:8080`) and API key.
### Option 2: Manual
Copy the `custom_components/wled_screen_controller/` folder from this repository into your Home Assistant `config/custom_components/` directory, then restart Home Assistant and add the integration as above.
### Automation example
```yaml
automation:
- alias: "Start ambient lighting when TV turns on"
trigger:
- platform: state
entity_id: media_player.living_room_tv
to: "on"
action:
- service: switch.turn_on
target:
entity_id: switch.living_room_tv_processing
- alias: "Stop ambient lighting when TV turns off"
trigger:
- platform: state
entity_id: media_player.living_room_tv
to: "off"
action:
- service: switch.turn_off
target:
entity_id: switch.living_room_tv_processing
```
---
## Configuration Reference
The server reads configuration from three sources (in order of priority):
1. **Environment variables** -- prefix `WLED_`, double underscore as nesting delimiter (e.g., `WLED_SERVER__PORT=9090`)
2. **YAML config file** -- `server/config/default_config.yaml` (or set `WLED_CONFIG_PATH` to override)
1. **Environment variables** -- prefix `LEDGRAB_`, double underscore as nesting delimiter (e.g., `LEDGRAB_SERVER__PORT=9090`)
2. **YAML config file** -- `server/config/default_config.yaml` (or set `LEDGRAB_CONFIG_PATH` to override)
3. **Built-in defaults**
See [`server/.env.example`](server/.env.example) for every available variable with descriptions.
@@ -253,13 +209,14 @@ See [`server/.env.example`](server/.env.example) for every available variable wi
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `WLED_SERVER__PORT` | `8080` | HTTP listen port |
| `WLED_SERVER__LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
| `WLED_SERVER__CORS_ORIGINS` | `["http://localhost:8080"]` | Allowed CORS origins (JSON array) |
| `WLED_AUTH__API_KEYS` | `{"dev":"development-key..."}` | API keys (JSON object) |
| `WLED_MQTT__ENABLED` | `false` | Enable MQTT for HA auto-discovery |
| `WLED_MQTT__BROKER_HOST` | `localhost` | MQTT broker address |
| `WLED_DEMO` | `false` | Enable demo mode (sandbox with virtual devices) |
| `LEDGRAB_SERVER__PORT` | `8080` | HTTP listen port |
| `LEDGRAB_SERVER__LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
| `LEDGRAB_SERVER__CORS_ORIGINS` | `["http://localhost:8080"]` | Allowed CORS origins (JSON array) |
| `LEDGRAB_AUTH__API_KEYS` | `{"dev":"development-key..."}` | API keys (JSON object) |
| `LEDGRAB_STORAGE__DATABASE_FILE` | `data/ledgrab.db` | SQLite database path |
| `LEDGRAB_MQTT__ENABLED` | `false` | Enable MQTT for HA auto-discovery |
| `LEDGRAB_MQTT__BROKER_HOST` | `localhost` | MQTT broker address |
| `LEDGRAB_DEMO` | `false` | Enable demo mode (sandbox with virtual devices) |
---
@@ -276,7 +233,7 @@ python --version # must be 3.11+
**Check the frontend bundle exists:**
```bash
ls server/src/wled_controller/static/dist/app.bundle.js
ls server/src/ledgrab/static/dist/app.bundle.js
```
If missing, run `cd server && npm ci && npm run build`.
@@ -288,7 +245,7 @@ If missing, run `cd server && npm ci && npm run build`.
docker compose logs -f
# Manual install
tail -f logs/wled_controller.log
tail -f logs/ledgrab.log
```
### Cannot access the dashboard from another machine
@@ -297,13 +254,6 @@ tail -f logs/wled_controller.log
2. Check your firewall allows inbound traffic on port 8080.
3. Add your server's LAN IP to `cors_origins` (see [Configure CORS](#configure-cors-for-lan-access) above).
### Home Assistant integration not appearing
1. Verify HACS installed the component: check that `config/custom_components/wled_screen_controller/` exists.
2. Clear your browser cache.
3. Restart Home Assistant.
4. Check logs at **Settings > System > Logs** and search for `wled_screen_controller`.
### WLED device not responding
1. Confirm the device is powered on and connected to Wi-Fi.
@@ -324,4 +274,4 @@ tail -f logs/wled_controller.log
- [API Documentation](docs/API.md)
- [Calibration Guide](docs/CALIBRATION.md)
- [Repository Issues](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues)
- [Repository Issues](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/issues)
+31 -15
View File
@@ -87,8 +87,8 @@ A Home Assistant integration exposes devices as entities for smart home automati
### Docker (recommended)
```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
cd wled-screen-controller/server
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd ledgrab/server
docker compose up -d
```
@@ -97,8 +97,8 @@ docker compose up -d
Requires Python 3.11+ and Node.js 18+.
```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
cd wled-screen-controller/server
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd ledgrab/server
# Build the frontend bundle
npm ci && npm run build
@@ -112,7 +112,7 @@ pip install .
# Start the server
export PYTHONPATH=$(pwd)/src # Linux/Mac
# set PYTHONPATH=%CD%\src # Windows
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
uvicorn ledgrab.main:app --host 0.0.0.0 --port 8080
```
Open **http://localhost:8080** to access the dashboard.
@@ -121,12 +121,32 @@ Open **http://localhost:8080** to access the dashboard.
See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including configuration, Docker manual builds, and Home Assistant setup.
## Demo Mode
Demo mode runs the server with virtual devices, sample data, and isolated storage — useful for exploring the UI without real hardware.
Set the `LEDGRAB_DEMO` environment variable to `true`, `1`, or `yes`:
```bash
# Docker
docker compose run -e LEDGRAB_DEMO=true server
# Python
LEDGRAB_DEMO=true uvicorn ledgrab.main:app --host 0.0.0.0 --port 8081
# Windows (installed app)
set LEDGRAB_DEMO=true
LedGrab.bat
```
Demo mode uses port **8081**, config file `config/demo_config.yaml`, and stores data in `data/demo/` (separate from production data). It can run alongside the main server.
## Architecture
```text
wled-screen-controller/
ledgrab/
├── server/ # Python FastAPI backend
│ ├── src/wled_controller/
│ ├── src/ledgrab/
│ │ ├── main.py # Application entry point
│ │ ├── config.py # YAML + env var configuration
│ │ ├── api/
@@ -151,8 +171,6 @@ wled-screen-controller/
│ ├── tests/ # pytest suite
│ ├── Dockerfile
│ └── docker-compose.yml
├── custom_components/ # Home Assistant integration (HACS)
│ └── wled_screen_controller/
├── docs/
│ ├── API.md # REST API reference
│ └── CALIBRATION.md # LED calibration guide
@@ -162,7 +180,7 @@ wled-screen-controller/
## Configuration
Edit `server/config/default_config.yaml` or use environment variables with the `WLED_` prefix:
Edit `server/config/default_config.yaml` or use environment variables with the `LEDGRAB_` prefix:
```yaml
server:
@@ -180,11 +198,11 @@ storage:
logging:
format: "json"
file: "logs/wled_controller.log"
file: "logs/ledgrab.log"
max_size_mb: 100
```
Environment variable override example: `WLED_SERVER__PORT=9090`.
Environment variable override example: `LEDGRAB_SERVER__PORT=9090`.
## API
@@ -214,9 +232,7 @@ See [docs/CALIBRATION.md](docs/CALIBRATION.md) for a step-by-step guide.
## Home Assistant
Install via HACS (add as a custom repository) or manually copy `custom_components/wled_screen_controller/` into your HA config directory. The integration creates light, switch, sensor, and number entities for each configured device.
See [INSTALLATION.md](INSTALLATION.md) for detailed setup instructions.
For Home Assistant integration, see the separate [ledgrab-haos-integration](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab-haos-integration) repository.
## Development
+63
View File
@@ -0,0 +1,63 @@
## v0.6.0 (2026-05-01)
This release adds **device-event notifications** (snack + Web Notifications), a **daylight/timezone-aware streaming pipeline** with a new camera engine, a **redesigned Targets surface** built on the dashboard's mod-card system, a **tighter LED hot path** with allocation-free per-frame work, and a **revamped Release Notes overlay** with clickable asset downloads. Plus a wide pass of modal, toolbar, and settings polish across the WebUI.
### Features
- **Device event notifications** — configurable per-event channel matrix (none / snack / OS / both) for target online/offline, new WLED/serial discovery, and devices going missing. Backed by a long-running mDNS browser + 10 s serial poller, a startup-grace / flap-debounce / bulk-coalesce pipeline, and a new Notifications tab in Settings (en/ru/zh). ([8aa3a32](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8aa3a32))
- **Daylight + timezone streaming** — new `daylight_settings` module and `daylight-tz` frontend helper expand the daylight stream's behavior; capture path additions land alongside a new **camera engine** test suite. ([fdac26b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdac26b))
- **Targets cards migrated to the mod-card system** — LED targets and HA Light targets now share the dashboard's instrument-readout vocabulary (mod-head / mod-leds / mod-metrics / mod-foot, kebab menu, badges, chips, patch indicator). LED preview, FPS sparkline, and pipeline metrics preserved via an `extraHtml` escape hatch. ([233b463](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/233b463))
- **Target pipeline as a compact strip + chip row** — drops the legacy "Pipeline details" collapsible block; an always-visible 4 px segmented timing bar (extract / map / smooth / send for video, read / fft / render / send for audio) sits above an inline chip row showing total ms / frames / keepalives, animating smoothly between samples. ([51eebf2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/51eebf2))
- **Targets metrics aligned with the dashboard** — FPS sparkline now lives inside the FPS cell, Uptime gets a clock icon, Errors gets ok/warning by count, FPS readout adopts the dashboard `current/target avg N.N` shape, and the grid sizes so values like `1m 43s` no longer truncate at typical desktop widths. ([9067db2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9067db2))
- **Release Notes overlay v2** — new masthead with display-font title, tag/published/pre-release chip strip, and close/external actions; markdown body fuzzy-matches `<code>` filenames to release assets and renders clickable download links with per-asset descriptions (Windows installer/portable/msi, Linux tarball/AppImage/deb/rpm, macOS dmg/pkg, Android apk/aab, iOS ipa). Checksum/signature side-files are hidden. ([9d4a534](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9d4a534))
- **Tutorials expansion** — sub-tab switching, breadcrumb header, and prepare/switchSubTab hooks let tours open/close the dashboard customize panel and resolve targets behind sub-tabs; new steps for integrations, dashboard customize panel (presets / global / sections / perf cells), targets, scenes, and sync-clocks (en/ru/zh). ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
- **Cards / settings / modal / toolbar polish** — reworked mod-card colors, sections, channel-stripe styling, hairline borders, and signal-flow animation on running cards; multiselect bulk toolbar gets explicit Select-all / Deselect-all icons with luxury-gradient toolbar styling; Settings tabs are now icon-only (no overflow at any locale); modal exit animation gains symmetric fadeOut + slideDown keyframes with reduced-motion support; locale picker collapses to EN / RU / ZH; snack toast adopts a glass background with per-type accent. ([a56569b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a56569b))
- **Suppress browser auto-open on Windows login** — when "Start with Windows" is enabled, the autostart shortcut now passes `--autostart` so the WebUI tab no longer pops on every login. Manual launches and the installer's "Launch LedGrab" finish-page action are unchanged. ([de13f44](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/de13f44))
- **Simpler segment payloads** — `SegmentPayload.start` defaults to 0 and `length` defaults to "the rest of the strip from start". A single segment with only `mode` + `color` now fills the entire strip — no more `length: 9999` magic value clients had to pass. ([1c9acc5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c9acc5))
- About panel now houses the author + contact details that previously lived in a global app footer, freeing up vertical space across every page (en/ru/zh `donation.about_author` key added). ([816a27d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/816a27d))
### Performance
- **LED hot path is allocation-free per-frame**: Adalight gets a dedicated single-worker tx executor, pre-allocated wire buffer, uint8 scratch, and a precomputed header struct; DDP gets a pre-built `struct.Struct` and memoryview emit path; calibration precomputes Phase 3 skip-LED resampling so per-frame work is now `np.take` + in-place blend; the WLED target processor gets a matching tightening. ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
### Bug Fixes
- **Audio-source modal preserves device on refresh** — refresh button moved into the label row (no more overflow past the Source panel edge); selection is restored by matching on `(index, loopback)` first with a trimmed-name fallback for OS-side reindexing; the EntitySelect trigger now syncs so the visible label matches the underlying `<select>` in edit mode. ([0980cf4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0980cf4))
- **PWA meta tag** — add the standard `mobile-web-app-capable` tag while keeping the Apple variant for iOS Safari, since Chrome deprecated `apple-mobile-web-app-capable`. ([8e109f3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8e109f3))
---
### Development / Internal
#### CI/Build
- Add `workflow_dispatch` and skip lint/test on release commits (release.yml already runs in parallel; manual dispatch covers re-runs on demand). ([033c1f6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/033c1f6))
#### Tests
- New `test_camera_engine` suite covers the new capture path. ([fdac26b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdac26b))
- Adalight + DDP tests cover header format, buffer reuse, non-contiguous input, brightness scaling, RGB/RGBW packets, sequence/PUSH semantics, and multi-packet fragmentation. ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
- 13 new tests for the device-event notifications backend (full suite still 899 passing). ([8aa3a32](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8aa3a32))
- `conftest` pre-creates the test DB so `main.py`'s legacy-data migration no longer shovels the user's production DB into the test temp dir; `test_preferences_notifications` wipes its own setting at the start of the defaults test (was relying on isolation it never enforced). ([9d4a534](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9d4a534))
#### Tooling
- `.mcp.json` checked in with code-review-graph MCP server config so the graph tools are available out of the box. ([797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806))
---
<details>
<summary>All Commits</summary>
| Hash | Message | Author |
|------|---------|--------|
| [0980cf4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0980cf4) | fix(ui): audio-source modal — preserve device on refresh, relocate refresh action | alexei.dolgolyov |
| [fdac26b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdac26b) | feat: daylight tz, camera engine, value stream + modal/UI polish | alexei.dolgolyov |
| [816a27d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/816a27d) | refactor(ui): drop app footer, move author info to About panel | alexei.dolgolyov |
| [797b806](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/797b806) | feat: LED hot-path perf, tutorials expansion, modal markup polish | alexei.dolgolyov |
| [9d4a534](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9d4a534) | feat(ui): release notes overlay v2 + settings/streams/dashboard polish | alexei.dolgolyov |
| [51eebf2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/51eebf2) | feat(ui): redesign target pipeline as compact strip + chip row | alexei.dolgolyov |
| [9067db2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9067db2) | feat(ui): align Targets metric cells with dashboard pattern | alexei.dolgolyov |
| [233b463](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/233b463) | feat(ui): migrate Targets cards to mod-card system | alexei.dolgolyov |
| [de13f44](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/de13f44) | feat(autostart): suppress browser auto-open on Windows login | alexei.dolgolyov |
| [1c9acc5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c9acc5) | feat(api-input): make SegmentPayload start/length optional | alexei.dolgolyov |
| [a56569b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a56569b) | feat(ui): cards redesign + settings, modal, toolbar polish | alexei.dolgolyov |
| [8aa3a32](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8aa3a32) | feat(notifications): device event notifications (snack + Web Notifications) | alexei.dolgolyov |
| [8e109f3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8e109f3) | fix(pwa): add mobile-web-app-capable meta tag | alexei.dolgolyov |
| [033c1f6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/033c1f6) | ci: add workflow_dispatch and skip lint/test on release commits | alexei.dolgolyov |
</details>
-114
View File
@@ -1,114 +0,0 @@
# TODO
## IMPORTANT: Remove WLED naming throughout the app
- [ ] Rename all references to "WLED" in user-facing strings, class names, module names, config keys, file paths, and documentation
- [ ] The app is **LedGrab** — not tied to WLED specifically. WLED is just one of many supported output protocols
- [ ] Audit: i18n keys, page titles, tray labels, installer text, pyproject.toml description, README, CLAUDE.md, context files, API docs
- [ ] Rename `wled_controller` package → decide on new package name (e.g. `ledgrab`)
- [ ] Update import paths, entry points, config references, build scripts, Docker, CI/CD
- [ ] **Migration required** if renaming storage paths or config keys (see data migration policy in CLAUDE.md)
---
## Donation / Open-Source Banner
- [ ] Add a persistent but dismissible banner or notification in the dashboard UI informing users that the project is open-source and under active development, and that donations are highly appreciated
- [ ] Include a link to the donation page (GitHub Sponsors, Ko-fi, or similar — decide on platform)
- [ ] Remember dismissal in localStorage so it doesn't reappear every session
- [ ] Add i18n keys for the banner text (`en.json`, `ru.json`, `zh.json`)
---
# Color Strip Source Improvements
## New Source Types
- [x] **`weather`** — Weather-reactive ambient: maps weather conditions (rain, snow, clear, storm) to colors/animations via API
- [ ] **`music_sync`** — Beat-synced patterns: BPM detection, energy envelope, drop detection (higher-level than raw `audio`)
- [ ] **`math_wave`** — Mathematical wave generator: user-defined sine/triangle/sawtooth expressions, superposition
- [ ] **`text_scroll`** — Scrolling text marquee: bitmap font rendering, static text or RSS/API data source *(delayed)*
### Discuss: `home_assistant`
Need to research HAOS communication options first (WebSocket API, REST API, MQTT, etc.) before deciding scope.
### Deferred
- `image` — Static image sampler *(not now)*
- `clock` — Time display *(not now)*
## Improvements to Existing Sources
### `effect` (now 12 types)
- [x] Add effects: rain, comet, bouncing ball, fireworks, sparkle rain, lava lamp, wave interference
- [x] Custom palette support: user-defined [[pos,R,G,B],...] stops via JSON textarea
### `gradient`
- [x] Noise-perturbed gradient: value noise displacement on stop positions (`noise_perturb` animation type)
- [x] Gradient hue rotation: `hue_rotate` animation type — preserves S/V, rotates H
- [x] Easing functions between stops: linear, ease_in_out (smoothstep), step, cubic
### `audio`
- [x] New audio source type: band extractor (bass/mid/treble split) — responsibility of audio source layer, not CSS
- [ ] Peak hold indicator: global option on audio source (not per-mode), configurable decay time
### `daylight`
- [x] Longitude support for accurate solar position (NOAA solar equations)
- [x] Season awareness (day-of-year drives sunrise/sunset via solar declination)
### `candlelight`
- [x] Wind simulation: correlated flicker bursts across all candles (wind_strength 0.0-2.0)
- [x] Candle type presets: taper (steady), votive (flickery), bonfire (chaotic) — applied at render time
- [x] Wax drip effect: localized brightness dips with fade-in/fade-out recovery
### `composite`
- [ ] Allow nested composites (with cycle detection)
- [x] More blend modes: overlay, soft light, hard light, difference, exclusion
- [x] Per-layer LED range masks (optional start/end/reverse on each composite layer)
### `notification`
- [x] Chase effect (light bounces across strip with glowing tail)
- [x] Gradient flash (bright center fades to edges, exponential decay)
- [x] Queue priority levels (color_override = high priority, interrupts current)
### `api_input`
- [ ] Crossfade transition when new data arrives
- [ ] Interpolation when incoming LED count differs from strip count
- [ ] Last-write-wins from any client (no multi-source blending)
## Architectural / Pipeline
### Processing Templates (CSPT)
- [x] HSL shift filter (hue rotation + lightness adjustment)
- [x] ~~Color temperature filter~~ — already exists as `color_correction`
- [x] Contrast filter
- [x] ~~Saturation filter~~ — already exists
- [x] ~~Pixelation filter~~ — already exists as `pixelate`
- [x] Temporal blur filter (blend frames over time)
### Transition Engine
Needs deeper design discussion. Likely a new entity type `ColorStripSourceTransition` that defines how source switches happen (crossfade, wipe, etc.). Interacts with automations when they switch a target's active source.
### Deferred
- Global BPM sync *(not sure)*
- Recording/playback *(not now)*
- Source preview in editor modal *(not needed — overlay preview on devices is sufficient)*
---
## Remaining Open Discussion
1. **`home_assistant` source** — Need to research HAOS communication protocols first
2. **Transition engine** — Design as `ColorStripSourceTransition` entity: what transition types? (crossfade, wipe, dissolve?) How does a target reference its transition config? How do automations trigger it?
+54
View File
@@ -0,0 +1,54 @@
# TODO
## Donation / Open-Source Banner
- [x] Add a persistent but dismissible banner or notification in the dashboard UI informing users that the project is open-source and under active development, and that donations are highly appreciated
- [x] Include a link to the donation page (GitHub Sponsors, Ko-fi, or similar — decide on platform)
- [x] Remember dismissal in localStorage so it doesn't reappear every session
- [x] Add i18n keys for the banner text (`en.json`, `ru.json`, `zh.json`)
- [ ] Configure `DONATE_URL` and `REPO_URL` constants in `donation.ts` once platform is chosen
## Android Signing Secrets (Gitea)
The CI workflow `build-android.yml` produces a signed release APK **only** when all four secrets below are configured in Gitea → Settings → Secrets. When any one is missing, the "Guard release tag against missing keystore" step hard-fails a `v*` tag build — previously we silently shipped a debug-signed APK labeled as release.
| Secret | Contents |
| --- | --- |
| `ANDROID_KEYSTORE_BASE64` | Output of `base64 -w0 release.jks` — the whole keystore as one line |
| `ANDROID_KEYSTORE_PASSWORD` | Keystore password (the `-storepass` passed to `keytool`) |
| `ANDROID_KEY_ALIAS` | Key alias (e.g. `ledgrab-release`) |
| `ANDROID_KEY_PASSWORD` | Key password (can be the same as keystore password) |
### Generate the keystore (one-time, ~2 min)
```bash
keytool -genkeypair -v \
-storetype JKS \
-keystore release.jks \
-alias ledgrab-release \
-keyalg RSA -keysize 4096 \
-validity 9125 \
-dname "CN=LedGrab, O=Dolgolyov, C=BY"
base64 -w0 release.jks > release.jks.b64 # Linux / Git Bash
# Windows alternative:
# certutil -encode release.jks release.jks.b64
# (strip the -----BEGIN/END CERTIFICATE----- header/footer lines)
```
### Critical — back up `release.jks` outside the repo
- 1Password attachment, encrypted USB stick, or printed hex + password written down somewhere physical.
- Losing the keystore = every existing sideloaded install is permanently unable to upgrade. The only workaround is uninstall-then-reinstall, which wipes user data.
- The `release.jks` file itself must **never** be committed to git. Only the base64 string lives in Gitea secrets.
### Why it matters even without Play Store
Android's package manager refuses to install an upgrade whose signature differs from the currently-installed APK's signature — enforced by the OS, not Play. So once users install a build signed by key X, every future build they can upgrade to must also be signed by key X.
### Current state
- [ ] Generate `release.jks` with `keytool` (above) and back it up
- [ ] Upload the four secrets to Gitea
- [ ] Tag a throwaway `v0.4.1-test` to verify signed release APK is produced (then delete the tag + release)
- [ ] Note: any existing `v0.4.0` debug-signed install cannot upgrade to a release-signed v0.4.1 — users must uninstall first
+439 -23
View File
@@ -1,26 +1,442 @@
# Auto-Update Phase 1: Check & Notify
# LedGrab TODO
## Backend
- [ ] Add `packaging` to pyproject.toml dependencies
- [ ] Create `core/update/__init__.py`
- [ ] Create `core/update/release_provider.py` — ABC + data models
- [ ] Create `core/update/gitea_provider.py` — Gitea REST API implementation
- [ ] Create `core/update/version_check.py` — semver normalization + comparison
- [ ] Create `core/update/update_service.py` — background service + state machine
- [ ] Create `api/schemas/update.py` — Pydantic request/response models
- [ ] Create `api/routes/update.py` — REST endpoints
- [ ] Wire into `api/__init__.py`, `dependencies.py`, `main.py`
## Device Event Notifications
## Frontend
- [ ] Add update banner HTML to `index.html`
- [ ] Add Updates tab to `settings.html`
- [ ] Add `has-update` CSS styles for version badge in `layout.css`
- [ ] Add update banner CSS styles in `components.css`
- [ ] Create `features/update.ts` — update check/settings/banner logic
- [ ] Wire exports in `app.ts`
- [ ] Add i18n keys to `en.json`, `ru.json`, `zh.json`
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).
## Verification
- [ ] Lint check: `ruff check src/ tests/ --fix`
- [ ] TypeScript check: `npx tsc --noEmit && npm run build`
- [ ] Tests pass: `py -3.13 -m pytest tests/ --no-cov -q`
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
- [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. Full plan: [docs/plans/device-typed-configs.md](docs/plans/device-typed-configs.md).
- [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`
+20
View File
@@ -0,0 +1,20 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/app/build
/captures
.externalNativeBuild
.cxx
local.properties
# Chaquopy build cache
.build-cache/
# Python source junction (points at ../server/src/ledgrab — do not commit)
/app/src/main/python/ledgrab
# Signing keystore decoded from CI secrets
/keystore/
+183
View File
@@ -0,0 +1,183 @@
import java.util.concurrent.TimeUnit
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.chaquo.python")
}
// Derive versionCode from git commit count so sideload/Play upgrades
// always see a strictly-greater value without anyone remembering to
// bump by hand. ANDROID_VERSION_CODE env var wins for CI pipelines
// that prefer explicit values; fallback is `1` when neither is
// available (e.g. building from a tarball with no .git).
val ledgrabVersionCode: Int = run {
System.getenv("ANDROID_VERSION_CODE")?.toIntOrNull()?.let { return@run it }
try {
val process = ProcessBuilder("git", "rev-list", "--count", "HEAD")
.directory(rootDir)
.redirectErrorStream(true)
.start()
if (process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0) {
process.inputStream.bufferedReader().readText().trim().toIntOrNull() ?: 1
} else {
1
}
} catch (_: Exception) {
1
}
}
android {
namespace = "com.ledgrab.android"
compileSdk = 34
defaultConfig {
applicationId = "com.ledgrab.android"
minSdk = 24 // Android 7.0 — covers nearly all TV boxes
targetSdk = 34
// Derived from git commit count (or ANDROID_VERSION_CODE env var
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
// sideload updates silently refused to install.
versionCode = ledgrabVersionCode
versionName = "0.6.0"
ndk {
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
// emulators), x86 (legacy emulators). Wheels in android/wheels/
// must be kept in sync — see build-scripts/build-pydantic-core.sh.
abiFilters += listOf("arm64-v8a", "x86_64", "x86")
}
}
// Per-ABI APK splits — reduces download size by ~60% vs universal APK.
// Each split contains only one native ABI's shared libraries + wheels.
splits {
abi {
isEnable = true
reset()
include("arm64-v8a", "x86_64", "x86")
isUniversalApk = true // also produce a fat APK for sideloading
}
}
// Signing config from env vars (CI) — only registered when all four are set.
// Local release builds fall back to the debug signing config.
val ciKeystorePath = System.getenv("ANDROID_KEYSTORE_PATH")
val ciKeystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
val ciKeyAlias = System.getenv("ANDROID_KEY_ALIAS")
val ciKeyPassword = System.getenv("ANDROID_KEY_PASSWORD")
val hasCiSigning = listOf(ciKeystorePath, ciKeystorePassword, ciKeyAlias, ciKeyPassword)
.all { !it.isNullOrBlank() } && file(ciKeystorePath!!).exists()
signingConfigs {
if (hasCiSigning) {
create("release") {
storeFile = file(ciKeystorePath!!)
storePassword = ciKeystorePassword
keyAlias = ciKeyAlias
keyPassword = ciKeyPassword
}
}
}
buildTypes {
release {
// TODO(minify): keep R8 disabled until Chaquopy reflection is
// verified end-to-end. Chaquopy resolves Kotlin classes & static
// methods (PythonBridge, UsbSerialBridge, Root) by name from
// Python via PyObject — silent stripping breaks the app at
// runtime, after release. proguard-rules.pro contains keep
// rules covering the known entry points, but until we have
// a release smoke test that exercises every PyObject path we
// do NOT ship a minified release.
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = if (hasCiSigning) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
chaquopy {
defaultConfig {
version = "3.11"
pip {
// Pre-built wheels directory (for pydantic-core etc.) as an
// absolute file:// URI with forward slashes. Pip requires
// exactly three slashes after `file:` for an empty-host local
// path; the path-normalization differs by platform:
// Windows: absolute path is "C:/..." → prefix "file:///"
// Linux: absolute path is "/..." → prefix "file://"
// Using a fixed "file:///" prefix on both made CI produce
// "file:////workspace/…", which pip rejects as non-local.
val wheelsPath = rootDir.absolutePath.replace("\\", "/")
val wheelsUrlPrefix = if (wheelsPath.startsWith("/")) "file://" else "file:///"
options("--find-links", "$wheelsUrlPrefix$wheelsPath/wheels/")
// ── Android-compatible dependencies ─────────────────
// Listed explicitly because pyproject.toml includes
// desktop-only packages with no Android wheels.
// See CLAUDE.md "Android Dependency Sync" for policy.
install("fastapi")
install("uvicorn") // without [standard] — no uvloop/httptools
install("httpx")
install("numpy")
install("pydantic") // needs pydantic-core wheel in wheels/
install("pydantic-settings")
install("PyYAML")
install("structlog")
install("python-json-logger")
install("python-dateutil")
install("python-multipart")
install("jinja2")
install("zeroconf")
install("aiomqtt")
install("openrgb-python")
// opencv-python-headless: no cp311 Android wheel on Chaquopy.
// LedGrab's cv2 usage is guarded with try/except ImportError
// and falls back to numpy/Pillow alternatives on Android.
install("Pillow")
install("websockets")
install("cryptography") // AES-GCM secret-box for HA/MQTT credentials
}
}
}
// LedGrab Python source is included via a directory junction:
// android/app/src/main/python/ledgrab -> server/src/ledgrab
// This is the standard Chaquopy way to include local Python packages.
// Create the junction (run from repo root, no admin needed):
// cmd /c "mklink /J android\app\src\main\python\ledgrab server\src\ledgrab"
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.leanback:leanback:1.0.0")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.lifecycle:lifecycle-service:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
// QR code generation for displaying server URL on TV
implementation("com.google.zxing:core:3.5.3")
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
// driving Adalight/AmbiLED controllers plugged into Android TV boxes.
implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
}
+27
View File
@@ -0,0 +1,27 @@
# LedGrab ProGuard / R8 rules.
#
# IMPORTANT: Chaquopy resolves Java/Kotlin classes and static methods by
# name from Python (e.g. UsbSerialBridge.INSTANCE.listDevices()) via
# reflection. Anything reachable through PyObject must be kept by name
# or the release build will throw NoSuchMethod / ClassNotFound at
# runtime silently, only on the user's device.
#
# Keep ALL of com.ledgrab.android.* members for safety. The app is
# small enough that the size win from stripping these isn't worth the
# fragility.
-keep class com.ledgrab.android.** { *; }
# Chaquopy runtime itself.
-keep class com.chaquo.python.** { *; }
-dontwarn com.chaquo.python.**
# usb-serial-for-android driver classes are loaded via the prober's
# default device-id list, which uses reflection in some chip drivers.
-keep class com.hoho.android.usbserial.driver.** { *; }
-dontwarn com.hoho.android.usbserial.**
# Kotlin coroutines keep the debug agent off and the metadata intact.
-dontwarn kotlinx.coroutines.**
# Standard Android best-practice keeps.
-keepattributes Signature, InnerClasses, EnclosingMethod, *Annotation*
+101
View File
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- BLE scanning and connecting — API ≥31 uses granular permissions;
older releases need BLUETOOTH + ACCESS_FINE_LOCATION for scanning.
neverForLocation avoids the location permission dialog on API 31+. -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- BLE hardware — required=false so non-BT boxes still install. -->
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="false" />
<!-- Network access for WLED HTTP/UDP, web UI, MQTT -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- MediaProjection requires a foreground service -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<!-- POST_NOTIFICATIONS for Android 13+ foreground service notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Autostart on boot — BootReceiver spawns CaptureService in root
mode so capture resumes without the user touching the remote. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Exempt from Doze/App Standby so the FG service isn't killed
overnight on phones; essentially a no-op on TV boxes. -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Optional wake lock for sustained-performance boxes that aggressively
sleep the CPU with the display off. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Android TV declarations -->
<uses-feature
android:name="android.software.leanback"
android:required="true" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<!-- USB host — for USB-to-TTL adapters driving Adalight/AmbiLED
controllers. required=false so phones without USB host still install. -->
<uses-feature
android:name="android.hardware.usb.host"
android:required="false" />
<application
android:name=".LedGrabApp"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:banner="@drawable/ic_launcher"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.LedGrab">
<!-- TV launcher activity -->
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<!-- Foreground service for screen capture + Python server -->
<service
android:name=".CaptureService"
android:foregroundServiceType="mediaProjection"
android:exported="false" />
<!-- Autostart — fires on device boot (and package replace).
On rooted devices, launches CaptureService directly so capture
resumes without the user tapping Start. Unrooted devices are
no-op because MediaProjection consent cannot be bypassed. -->
<receiver
android:name=".BootReceiver"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -0,0 +1,26 @@
package com.ledgrab.android
import android.content.Context
/**
* Thin SharedPreferences wrapper for the boot-autostart toggle.
*
* Default is `true`: [BootReceiver] still bails out when the device
* isn't rooted, so a true default is safe — it only takes effect on
* boxes where we can actually honor it silently.
*/
class AutostartPrefs(context: Context) {
private val prefs = context.applicationContext
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
var isEnabled: Boolean
get() = prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED)
set(value) { prefs.edit().putBoolean(KEY_ENABLED, value).apply() }
companion object {
private const val PREFS_NAME = "ledgrab_autostart"
private const val KEY_ENABLED = "autostart_enabled"
private const val DEFAULT_ENABLED = true
}
}
@@ -0,0 +1,288 @@
package com.ledgrab.android
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import java.util.Collections
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.TimeoutCancellationException
/**
* Android BLE bridge exposed to the Python server via Chaquopy.
*
* Wraps the Android BluetoothGatt / BluetoothLeScanner APIs into
* synchronous, blocking calls that can be safely invoked from
* a Python thread (Chaquopy proxy threads are real OS threads).
*
* All GATT callbacks run on a private [HandlerThread] so they don't
* block the main looper. [runBlocking] is used to bridge callback
* completions back to the calling Python thread.
*
* Python callers access the singleton via
* `BleBridge.INSTANCE.scan()` etc. — see
* `server/src/ledgrab/core/devices/android_ble_transport.py`.
*/
object BleBridge {
private const val TAG = "BleBridge"
private const val CONNECT_TIMEOUT_MS = 18_000L // connect + service discovery
private const val WRITE_TIMEOUT_MS = 5_000L
@Volatile private var appContext: Context? = null
private val handleSeq = AtomicInteger(1)
// Dedicated looper thread so BLE callbacks don't land on the main thread.
private val bleHandlerThread = HandlerThread("LedGrab-BLE").also { it.start() }
private val bleHandler = Handler(bleHandlerThread.looper)
private data class GattHandle(
val gatt: BluetoothGatt,
val writeChar: BluetoothGattCharacteristic,
)
private val handles = ConcurrentHashMap<Int, GattHandle>()
// Write completion futures, keyed by handle. Only populated for
// WRITE_TYPE_DEFAULT (with-response) writes.
private val pendingWrites = ConcurrentHashMap<Int, CompletableDeferred<Boolean>>()
/** Called once from [LedGrabApp.onCreate] to bind the application context. */
@JvmStatic
fun init(context: Context) {
appContext = context.applicationContext
}
private fun ctx(): Context =
appContext ?: error("BleBridge.init() not called — app context unavailable")
private fun adapter() =
ctx().getSystemService(BluetoothManager::class.java)?.adapter
// ─── Public API ──────────────────────────────────────────────────────────
/**
* Scan for BLE peripherals for [timeoutMs] milliseconds.
*
* Returns a list of `"address|name|rssi"` strings. Addresses are
* deduplicated — only the last-seen RSSI for each address is kept.
* Returns an empty list if Bluetooth is off or the permission is denied.
*/
@JvmStatic
@JvmOverloads
fun scan(timeoutMs: Long = 4_000L): List<String> {
val adapter = adapter() ?: return emptyList()
if (!adapter.isEnabled) return emptyList()
val scanner = try { adapter.bluetoothLeScanner } catch (_: SecurityException) { null }
?: return emptyList()
val seen = Collections.synchronizedMap(LinkedHashMap<String, String>())
val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val address = result.device.address ?: return
val name = result.scanRecord?.deviceName ?: result.device.name ?: ""
seen[address] = "$address|$name|${result.rssi}"
}
override fun onScanFailed(errorCode: Int) {
Log.w(TAG, "BLE scan failed with error $errorCode")
}
}
try {
bleHandler.post { scanner.startScan(callback) }
Thread.sleep(timeoutMs)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
} finally {
try { bleHandler.post { scanner.stopScan(callback) } } catch (_: SecurityException) {}
}
return seen.values.toList()
}
/**
* Connect to the BLE peripheral at [address] and locate the GATT
* characteristic identified by [writeCharUuid] across all services.
*
* Blocks until connected + services discovered, or returns -1 on failure.
* The returned integer is an opaque handle passed to [write]/[disconnect].
*/
@JvmStatic
fun connect(address: String, writeCharUuid: String): Int {
val adapter = adapter() ?: return -1
val device = try { adapter.getRemoteDevice(address) } catch (e: Exception) {
Log.e(TAG, "Invalid BLE address '$address': ${e.message}")
return -1
}
val readyDeferred = CompletableDeferred<Boolean>()
val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
when {
newState == BluetoothProfile.STATE_CONNECTED
&& status == BluetoothGatt.GATT_SUCCESS -> {
Log.d(TAG, "GATT connected to $address, discovering services")
gatt.discoverServices()
}
newState == BluetoothProfile.STATE_DISCONNECTED -> {
Log.w(TAG, "GATT disconnected from $address (status=$status)")
readyDeferred.complete(false)
}
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
readyDeferred.complete(status == BluetoothGatt.GATT_SUCCESS)
}
override fun onCharacteristicWrite(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int,
) {
val h = handles.entries.firstOrNull { it.value.gatt === gatt }?.key ?: return
pendingWrites.remove(h)?.complete(status == BluetoothGatt.GATT_SUCCESS)
}
}
val gatt: BluetoothGatt = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
device.connectGatt(
ctx(), false, callback,
android.bluetooth.BluetoothDevice.TRANSPORT_LE,
android.bluetooth.BluetoothDevice.PHY_LE_1M_MASK,
bleHandler,
)
} else {
@Suppress("DEPRECATION")
device.connectGatt(
ctx(), false, callback,
android.bluetooth.BluetoothDevice.TRANSPORT_LE,
)
}
} catch (e: SecurityException) {
Log.e(TAG, "BLUETOOTH_CONNECT permission denied for $address", e)
return -1
} catch (e: Exception) {
Log.e(TAG, "connectGatt failed for $address", e)
return -1
}
val ready = try {
runBlocking { withTimeout(CONNECT_TIMEOUT_MS) { readyDeferred.await() } }
} catch (_: TimeoutCancellationException) {
Log.e(TAG, "BLE connect+discovery timed out for $address")
runCatching { gatt.close() }
return -1
}
if (!ready) {
runCatching { gatt.close() }
return -1
}
val charUuid = try { UUID.fromString(writeCharUuid) } catch (e: Exception) {
Log.e(TAG, "Invalid characteristic UUID '$writeCharUuid'")
gatt.disconnect(); gatt.close()
return -1
}
val writeChar = gatt.services.flatMap { it.characteristics }
.firstOrNull { it.uuid == charUuid }
if (writeChar == null) {
Log.e(TAG, "Characteristic $writeCharUuid not found on $address")
gatt.disconnect(); gatt.close()
return -1
}
val handle = handleSeq.getAndIncrement()
handles[handle] = GattHandle(gatt, writeChar)
Log.i(TAG, "BLE connected: address=$address char=$writeCharUuid handle=$handle")
return handle
}
/**
* Write [data] to the characteristic associated with [handle].
*
* [withResponse] controls the GATT write type:
* - `true` → Write Request (waits for device ACK, slower but reliable)
* - `false` → Write Command (fire-and-forget, faster, used by SP110E/Triones/Zengge)
*
* Returns `true` on success, `false` on any error.
*/
@JvmStatic
fun write(handle: Int, data: ByteArray, withResponse: Boolean): Boolean {
val entry = handles[handle] ?: return false
val gatt = entry.gatt
val char = entry.writeChar
val writeType = if (withResponse)
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
else
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
return if (withResponse) {
val deferred = CompletableDeferred<Boolean>()
pendingWrites[handle] = deferred
val initiated = gattWrite(gatt, char, data, writeType)
if (!initiated) {
pendingWrites.remove(handle)
return false
}
try {
runBlocking { withTimeout(WRITE_TIMEOUT_MS) { deferred.await() } }
} catch (_: TimeoutCancellationException) {
pendingWrites.remove(handle)
Log.w(TAG, "BLE write-with-response timed out on handle $handle")
false
}
} else {
gattWrite(gatt, char, data, writeType)
}
}
/** Disconnect and close the GATT connection for [handle]. */
@JvmStatic
fun disconnect(handle: Int) {
val entry = handles.remove(handle) ?: return
pendingWrites.remove(handle)?.complete(false)
runCatching {
entry.gatt.disconnect()
entry.gatt.close()
}.onFailure { Log.w(TAG, "BLE disconnect error for handle $handle: ${it.message}") }
Log.i(TAG, "BLE disconnected handle=$handle")
}
// ─── Internal helpers ─────────────────────────────────────────────────────
private fun gattWrite(
gatt: BluetoothGatt,
char: BluetoothGattCharacteristic,
data: ByteArray,
writeType: Int,
): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeCharacteristic(char, data, writeType) ==
android.bluetooth.BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
char.writeType = writeType
@Suppress("DEPRECATION")
char.value = data
@Suppress("DEPRECATION")
gatt.writeCharacteristic(char)
}
}
@@ -0,0 +1,62 @@
package com.ledgrab.android
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.content.ContextCompat
/**
* Starts the LedGrab capture service automatically on device boot and
* after the app is updated.
*
* Autostart is best-effort and deliberately limited:
* - Requires root. MediaProjection consent cannot be granted silently,
* so we can't resume capture on unrooted boxes without the user
* walking up to the TV and tapping Start.
* - Gated behind [AutostartPrefs] so the user can opt out.
*
* Receivers declared with BOOT_COMPLETED must be fast — we hand off to
* a foreground service within milliseconds and let the service do the
* heavy lifting (Chaquopy bootstrap, pipeline setup).
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
Log.i(TAG, "Boot event: $action")
if (action != Intent.ACTION_BOOT_COMPLETED
&& action != Intent.ACTION_LOCKED_BOOT_COMPLETED
&& action != Intent.ACTION_MY_PACKAGE_REPLACED
) {
return
}
if (!AutostartPrefs(context).isEnabled) {
Log.i(TAG, "Autostart disabled — skipping")
return
}
// Cheap check first (no process spawn). The real `su -c id` probe
// would block and may not complete before the OS reclaims us.
if (!Root.looksRooted()) {
Log.i(TAG, "Device not rooted — cannot autostart without MediaProjection consent")
return
}
try {
ContextCompat.startForegroundService(
context,
CaptureService.createRootIntent(context),
)
Log.i(TAG, "LedGrab capture service dispatched on boot")
} catch (e: Exception) {
Log.e(TAG, "Failed to start service on boot", e)
}
}
companion object {
private const val TAG = "BootReceiver"
}
}
@@ -0,0 +1,353 @@
package com.ledgrab.android
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.IBinder
import android.util.DisplayMetrics
import android.util.Log
import android.view.WindowManager
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Foreground service that runs the Python LedGrab server and captures
* the screen via MediaProjection.
*/
class CaptureService : Service() {
companion object {
private const val TAG = "CaptureService"
private const val CHANNEL_ID = "ledgrab_capture"
private const val NOTIFICATION_ID = 1
private const val EXTRA_RESULT_CODE = "result_code"
private const val EXTRA_RESULT_DATA = "result_data"
private const val EXTRA_USE_ROOT = "use_root"
private const val SERVER_PORT = 8080
private const val CAPTURE_WIDTH = 480
private const val CAPTURE_HEIGHT = 270
private const val CAPTURE_FPS = 30
// Watchdog: if no new root-capture frames arrive inside this
// window the pipeline is considered stalled and respawned.
// Grace gives screenrecord time to produce its first I-frame.
private const val WATCHDOG_GRACE_MS = 5_000L
private const val WATCHDOG_CHECK_MS = 5_000L
private const val WATCHDOG_MAX_RESTARTS = 3
/** True while the service is alive. Survives activity recreation. */
@Volatile
@JvmStatic
var isRunning: Boolean = false
private set
fun createIntent(
context: Context,
resultCode: Int,
resultData: Intent,
): Intent {
return Intent(context, CaptureService::class.java).apply {
putExtra(EXTRA_RESULT_CODE, resultCode)
putExtra(EXTRA_RESULT_DATA, resultData)
}
}
/** Root-mode intent — no MediaProjection consent data required. */
fun createRootIntent(context: Context): Intent {
return Intent(context, CaptureService::class.java).apply {
putExtra(EXTRA_USE_ROOT, true)
}
}
}
private var bridge: PythonBridge? = null
private var screenCapture: ScreenCapture? = null
private var rootCapture: RootScreenrecord? = null
private var mediaProjection: MediaProjection? = null
// Service-scoped coroutine scope for the root-capture watchdog.
// SupervisorJob so a watchdog crash doesn't tear down other children.
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var watchdogJob: Job? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// CRITICAL: startForeground must be called IMMEDIATELY —
// before any other work, especially before getMediaProjection().
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
val url = "http://$localIp:$SERVER_PORT"
try {
startForeground(NOTIFICATION_ID, buildNotification(url))
} catch (e: Exception) {
// Most common cause: missing foregroundServiceType permission
// or denied POST_NOTIFICATIONS on API 34+.
Log.e(TAG, "startForeground failed — service cannot run", e)
stopSelf()
return START_NOT_STICKY
}
// Only flip the public flag once the FG transition has succeeded,
// otherwise `isRunning=true` sticks forever when startForeground throws.
isRunning = true
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
if (intent == null && !useRoot) {
// MediaProjection mode can't recover from a redelivery —
// the consent token in the original intent is single-use.
Log.w(TAG, "Service restarted without intent (MediaProjection mode) — stopping")
isRunning = false
stopSelf()
return START_NOT_STICKY
}
try {
if (useRoot) {
startRootCapture(url)
} else {
startMediaProjectionCapture(intent!!, url)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start capture", e)
isRunning = false
stopSelf()
}
// Root mode can be cleanly restarted from the original intent
// because it carries no perishable consent token. MediaProjection
// mode cannot — a restart there would silently start the service
// with a dead projection. START_NOT_STICKY there, the user has
// to tap Start again.
return if (useRoot) START_REDELIVER_INTENT else START_NOT_STICKY
}
private fun startRootCapture(url: String) {
val newBridge = PythonBridge(this).also { b ->
b.configureRootCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
b.startServer(SERVER_PORT)
}
bridge = newBridge
val pipeline = RootScreenrecord(
bridge = newBridge,
width = CAPTURE_WIDTH,
height = CAPTURE_HEIGHT,
fps = CAPTURE_FPS,
)
if (!pipeline.start()) {
Log.w(TAG, "Root capture failed to launch — stopping service")
stopSelf()
return
}
rootCapture = pipeline
startWatchdog()
Log.i(TAG, "LedGrab service started (root mode) — web UI at $url")
}
/**
* Replace the active root pipeline with a fresh instance, reusing
* the existing Python bridge (no server restart). Returns true if
* the new pipeline launched, false otherwise.
*/
private fun restartRootPipeline(): Boolean {
val currentBridge = bridge ?: return false
val old = rootCapture
rootCapture = null
runCatching { old?.stop() }
val next = RootScreenrecord(
bridge = currentBridge,
width = CAPTURE_WIDTH,
height = CAPTURE_HEIGHT,
fps = CAPTURE_FPS,
)
if (!next.start()) {
Log.e(TAG, "Root capture failed to restart")
return false
}
rootCapture = next
return true
}
/**
* Monitor the root pipeline's frame counter. If no new frames
* arrive within one check window, respawn the pipeline. Caps at
* [WATCHDOG_MAX_RESTARTS] consecutive failures before giving up so
* we don't burn battery forever on a device that genuinely can't
* produce frames.
*/
private fun startWatchdog() {
watchdogJob?.cancel()
watchdogJob = serviceScope.launch {
delay(WATCHDOG_GRACE_MS)
var last = rootCapture?.framesDelivered ?: 0
var restartAttempts = 0
while (isActive) {
delay(WATCHDOG_CHECK_MS)
val pipe = rootCapture ?: break
val current = pipe.framesDelivered
if (current == last) {
restartAttempts += 1
Log.w(
TAG,
"Root capture stalled (no new frames in ${WATCHDOG_CHECK_MS}ms); " +
"restart attempt $restartAttempts/$WATCHDOG_MAX_RESTARTS",
)
if (restartAttempts > WATCHDOG_MAX_RESTARTS) {
Log.e(TAG, "Watchdog gave up after $WATCHDOG_MAX_RESTARTS restarts")
stopSelf()
return@launch
}
if (!restartRootPipeline()) {
stopSelf()
return@launch
}
last = rootCapture?.framesDelivered ?: 0
} else {
// Forward progress — forgive earlier glitches.
restartAttempts = 0
last = current
}
}
}
}
private fun startMediaProjectionCapture(intent: Intent, url: String) {
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
@Suppress("DEPRECATION")
val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
} else {
intent.getParcelableExtra(EXTRA_RESULT_DATA)
}
if (resultData == null) {
Log.e(TAG, "No MediaProjection result data")
stopSelf()
return
}
val projectionManager =
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val projection = projectionManager.getMediaProjection(resultCode, resultData)
if (projection == null) {
Log.e(TAG, "Failed to create MediaProjection")
stopSelf()
return
}
mediaProjection = projection
val metrics = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val windowMetrics = (getSystemService(Context.WINDOW_SERVICE) as WindowManager)
.currentWindowMetrics
DisplayMetrics().apply {
val bounds = windowMetrics.bounds
widthPixels = bounds.width()
heightPixels = bounds.height()
// densityDpi is still needed for VirtualDisplay; read from resources.
densityDpi = resources.displayMetrics.densityDpi
}
} else {
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
DisplayMetrics().also { m ->
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getRealMetrics(m)
}
}
val newBridge = PythonBridge(this).also { b ->
b.configureCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
b.startServer(SERVER_PORT)
}
bridge = newBridge
screenCapture = ScreenCapture(
projection = projection,
metrics = metrics,
bridge = newBridge,
targetWidth = CAPTURE_WIDTH,
targetHeight = CAPTURE_HEIGHT,
targetFps = CAPTURE_FPS,
// If the user taps the system Cast/Screen-capture stop banner,
// MediaProjection.Callback.onStop fires — tear the whole
// service down so the notification/Python server don't linger.
onProjectionStopped = { stopSelf() },
).also { it.start() }
Log.i(TAG, "LedGrab service started (MediaProjection) — web UI at $url")
}
override fun onDestroy() {
isRunning = false
watchdogJob?.cancel()
watchdogJob = null
serviceScope.cancel()
screenCapture?.stop()
screenCapture = null
rootCapture?.stop()
rootCapture = null
bridge?.stopServer()
bridge = null
mediaProjection?.stop()
mediaProjection = null
Log.i(TAG, "LedGrab service destroyed")
super.onDestroy()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"LedGrab Screen Capture",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Shows while LedGrab is capturing the screen"
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
private fun buildNotification(url: String): Notification {
val tapIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("LedGrab Running")
.setContentText("Web UI: $url")
.setSmallIcon(R.drawable.ic_launcher)
.setContentIntent(tapIntent)
.setOngoing(true)
.build()
}
}
@@ -0,0 +1,83 @@
package com.ledgrab.android
import android.app.Application
import android.util.Log
import com.chaquo.python.Python
import com.chaquo.python.android.AndroidPlatform
import java.io.File
import java.io.PrintWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Application class — initializes the Chaquopy Python runtime and
* installs a global uncaught exception handler that persists crash
* logs to app-private storage.
*
* `Python.start()` must be called once before any Python code runs.
* It loads libpython, extracts stdlib + pip packages from APK assets
* (first launch only), and sets up `sys.path`.
*/
class LedGrabApp : Application() {
/** Set if [Python.start] threw — surfaced by MainActivity. */
@Volatile
var initError: Throwable? = null
private set
override fun onCreate() {
super.onCreate()
installCrashLogger()
try {
if (!Python.isStarted()) {
Python.start(AndroidPlatform(this))
}
} catch (t: Throwable) {
// Don't crash here — MainActivity will render a failure
// screen with a Copy log button so the user can report it.
Log.e(TAG, "Python.start() failed", t)
initError = t
return
}
// Bind application context for the USB-serial bridge so Python
// can enumerate and open USB-to-TTL adapters without needing
// an Activity reference.
UsbSerialBridge.init(this)
// Bind application context for the BLE bridge so Python can
// scan and connect to BLE LED controllers.
BleBridge.init(this)
}
/**
* Install a global uncaught exception handler that writes the
* stack trace to `files/crash-<timestamp>.log` before letting
* the default handler terminate the process. Logs survive app
* restarts and can be pulled via `adb pull` for diagnostics.
*/
private fun installCrashLogger() {
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
val ts = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
val logFile = File(filesDir, "crash-$ts.log")
PrintWriter(logFile).use { pw ->
pw.println("LedGrab crash at $ts")
pw.println("Thread: ${thread.name}")
pw.println()
throwable.printStackTrace(pw)
}
Log.e(TAG, "Crash log written to ${logFile.absolutePath}")
} catch (_: Exception) {
// Best effort — don't crash inside the crash handler.
}
// Chain to the default handler so Android shows the crash dialog
// and terminates the process.
defaultHandler?.uncaughtException(thread, throwable)
}
}
companion object {
private const val TAG = "LedGrabApp"
}
}
@@ -0,0 +1,314 @@
package com.ledgrab.android
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.media.projection.MediaProjectionManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import android.view.View
import android.widget.Button
import android.widget.CheckBox
import android.widget.ImageView
import android.widget.TextView
import android.app.Activity
import androidx.core.content.ContextCompat
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Main (and only) Activity for the Android TV app.
*
* Two-state UI: stopped (Start button) and running (URL + QR + Stop).
* Navigable with D-pad / USB controller.
*/
class MainActivity : Activity() {
// Activity-scoped coroutine scope. We don't depend on AppCompat /
// androidx.lifecycle's lifecycleScope here because the TV launcher
// theme inherits from Leanback (non-AppCompat).
private val uiScope: CoroutineScope = MainScope()
companion object {
private const val TAG = "MainActivity"
private const val SERVER_PORT = 8080
private const val REQUEST_MEDIA_PROJECTION = 1001
private const val REQUEST_POST_NOTIFICATIONS = 1002
}
private lateinit var stoppedPanel: View
private lateinit var runningPanel: View
private lateinit var statusText: TextView
private lateinit var urlText: TextView
private lateinit var qrImage: ImageView
private lateinit var toggleButton: Button
private lateinit var stopButtonRunning: Button
private lateinit var versionText: TextView
private lateinit var autostartCheck: CheckBox
private lateinit var autostartPrefs: AutostartPrefs
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Surface fatal Python init errors instead of crashing.
val initError = (application as? LedGrabApp)?.initError
if (initError != null) {
showFatalErrorScreen(initError)
return
}
setContentView(R.layout.activity_main)
stoppedPanel = findViewById(R.id.stopped_panel)
runningPanel = findViewById(R.id.running_panel)
statusText = findViewById(R.id.status_text)
urlText = findViewById(R.id.url_text)
qrImage = findViewById(R.id.qr_image)
toggleButton = findViewById(R.id.toggle_button)
stopButtonRunning = findViewById(R.id.stop_button_running)
versionText = findViewById(R.id.version_text)
autostartCheck = findViewById(R.id.autostart_check)
val versionName = packageManager
.getPackageInfo(packageName, 0).versionName
versionText.text = getString(R.string.version_prefix, versionName ?: "?")
autostartPrefs = AutostartPrefs(this)
autostartCheck.isChecked = autostartPrefs.isEnabled
// Autostart only takes effect on rooted devices — grey it out
// on unrooted hardware so users don't expect magic. Cheap probe
// (file-existence only, no process spawn).
if (!Root.looksRooted()) {
autostartCheck.isEnabled = false
autostartCheck.text = getString(R.string.autostart_unavailable)
}
autostartCheck.setOnCheckedChangeListener { _, isChecked ->
autostartPrefs.isEnabled = isChecked
if (isChecked) ensureIgnoringBatteryOptimizations()
}
toggleButton.setOnClickListener { startCapture() }
stopButtonRunning.setOnClickListener { stopCaptureService() }
updateUI()
}
/**
* Decide whether to go through the MediaProjection consent flow or
* jump straight into root capture. Root check is fast but may block
* briefly the first time Magisk shows its grant dialog — running it
* on the UI thread is acceptable because we're responding to a
* button press and we want to block until the user answers.
*/
override fun onDestroy() {
uiScope.cancel()
super.onDestroy()
}
private fun startCapture() {
// `su -c id` can block for seconds while Magisk shows its grant
// dialog; running it on the Main thread caused ANRs.
toggleButton.isEnabled = false
statusText.text = "Checking root access…"
uiScope.launch(Dispatchers.IO) {
val rooted = Root.requestGrant()
withContext(Dispatchers.Main) {
toggleButton.isEnabled = true
statusText.text = ""
if (rooted) {
Log.i(TAG, "Root available — skipping MediaProjection consent")
startRootCaptureService()
} else {
requestMediaProjection()
}
}
}
}
private fun requestMediaProjection() {
val manager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
@Suppress("DEPRECATION")
startActivityForResult(manager.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION)
}
private fun startRootCaptureService() {
ensureNotificationPermission()
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
updateUI()
}
@Deprecated("Using deprecated API for plain Activity compatibility")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_MEDIA_PROJECTION) {
if (resultCode == RESULT_OK && data != null) {
startCaptureService(resultCode, data)
} else {
statusText.text = "Permission denied — screen capture requires authorization"
Log.w(TAG, "MediaProjection permission denied")
}
}
}
private fun startCaptureService(resultCode: Int, resultData: Intent) {
ensureNotificationPermission()
val intent = CaptureService.createIntent(this, resultCode, resultData)
ContextCompat.startForegroundService(this, intent)
updateUI()
}
private fun stopCaptureService() {
stopService(Intent(this, CaptureService::class.java))
updateUI()
}
private fun updateUI() {
if (CaptureService.isRunning) {
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
val url = "http://$localIp:$SERVER_PORT"
urlText.text = url
qrImage.setImageBitmap(null)
// Build the bitmap pixels off the Main thread — encode + 313k
// setPixel calls were noticeably janky on slow TV boxes.
uiScope.launch(Dispatchers.Default) {
val bitmap = generateQrCode(url)
withContext(Dispatchers.Main) {
if (CaptureService.isRunning && urlText.text == url) {
qrImage.setImageBitmap(bitmap)
}
}
}
stoppedPanel.visibility = View.GONE
versionText.visibility = View.GONE
runningPanel.visibility = View.VISIBLE
stopButtonRunning.requestFocus()
} else {
urlText.text = ""
qrImage.setImageBitmap(null)
runningPanel.visibility = View.GONE
stoppedPanel.visibility = View.VISIBLE
versionText.visibility = View.VISIBLE
toggleButton.requestFocus()
}
}
private fun generateQrCode(text: String): Bitmap {
val size = 560
val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size)
val pixels = IntArray(size * size)
for (y in 0 until size) {
val rowOffset = y * size
for (x in 0 until size) {
pixels[rowOffset + x] =
if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
}
}
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565)
bitmap.setPixels(pixels, 0, size, 0, 0, size, size)
return bitmap
}
/**
* Minimal failure UI shown when Python.start() (Chaquopy) blew up.
* Rendered programmatically so we don't depend on the regular layout
* (which itself may reference resources affected by the failure).
*/
private fun showFatalErrorScreen(error: Throwable) {
Log.e(TAG, "Fatal init error — showing error screen", error)
val stackText = android.util.Log.getStackTraceString(error)
val container = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
setPadding(48, 48, 48, 48)
}
val title = TextView(this).apply {
text = "LedGrab failed to start"
textSize = 22f
}
val body = TextView(this).apply {
text = "Python runtime initialization failed:\n\n$stackText"
textSize = 12f
setTextIsSelectable(true)
}
val copyBtn = Button(this).apply {
text = "Copy log"
setOnClickListener {
val cm = getSystemService(CLIPBOARD_SERVICE)
as android.content.ClipboardManager
cm.setPrimaryClip(
android.content.ClipData.newPlainText("LedGrab error", stackText)
)
}
}
val scroll = android.widget.ScrollView(this).apply { addView(body) }
container.addView(title)
container.addView(copyBtn)
container.addView(scroll)
setContentView(container)
}
/**
* Prompt the user to exempt LedGrab from battery optimization. On
* TV boxes this is usually a no-op, but on phones Doze/App Standby
* will kill the foreground service after a few hours of sleep. We
* only ask when autostart is turned on. No-op on pre-M or when
* already exempt.
*
* Play Store flags REQUEST_IGNORE_BATTERY_OPTIMIZATIONS by default
* — LedGrab's ambient-capture use case falls under the documented
* acceptable-use exceptions (a foreground service that must not be
* killed to fulfill its primary function).
*/
@SuppressLint("BatteryLife")
private fun ensureIgnoringBatteryOptimizations() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val pm = getSystemService(POWER_SERVICE) as PowerManager
if (pm.isIgnoringBatteryOptimizations(packageName)) return
try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
}
startActivity(intent)
} catch (e: Exception) {
// Some TV-box OEM builds strip this intent. Fall back to the
// generic settings screen so the user can find it manually.
Log.w(TAG, "Direct exemption intent unavailable: ${e.message}")
runCatching {
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
}
}
}
/**
* Request POST_NOTIFICATIONS permission on Android 13+ so the
* foreground service notification is visible. On older API levels
* this is a no-op.
*/
private fun ensureNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
@Suppress("DEPRECATION")
requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
REQUEST_POST_NOTIFICATIONS,
)
}
}
}
}
@@ -0,0 +1,28 @@
package com.ledgrab.android
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import java.net.Inet4Address
/**
* Network utilities for discovering the device's local IP address.
*/
object NetworkUtils {
/**
* Return the device's local IPv4 address on the active network,
* or `null` if unavailable.
*/
fun getLocalIpAddress(context: Context): String? {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return null
val props: LinkProperties = cm.getLinkProperties(network) ?: return null
return props.linkAddresses
.map { it.address }
.filterIsInstance<Inet4Address>()
.firstOrNull { !it.isLoopbackAddress }
?.hostAddress
}
}
@@ -0,0 +1,141 @@
package com.ledgrab.android
import android.content.Context
import android.util.Log
import com.chaquo.python.PyObject
import com.chaquo.python.Python
/**
* Bridge between Kotlin and the LedGrab Python server.
*
* All Python calls go through Chaquopy's `Python.getInstance()`.
* Frame data crosses the JNI boundary as a `ByteArray`.
*/
class PythonBridge(private val context: Context) {
companion object {
private const val TAG = "PythonBridge"
}
private var serverThread: Thread? = null
@Volatile private var running = false
// Cached PyObject handles for the per-frame fast path. Looking these
// up via Python.getInstance().getModule(...) every frame was a real
// measurable cost (~1ms/frame on TV boxes). Cached once at configure
// time and read on the capture thread — @Volatile is enough for the
// single-writer/single-reader pattern we have here.
@Volatile private var mediaProjectionEngine: PyObject? = null
@Volatile private var rootEngine: PyObject? = null
/**
* Configure the MediaProjection engine with screen dimensions.
* Must be called before [startServer].
*/
fun configureCapture(width: Int, height: Int) {
val py = Python.getInstance()
val engine = py.getModule("ledgrab.core.capture_engines.mediaprojection_engine")
engine.callAttr("configure", width, height)
mediaProjectionEngine = engine
Log.i(TAG, "MediaProjection engine configured: ${width}x${height}")
}
/**
* Configure the root screenrecord engine with screen dimensions.
* Used instead of [configureCapture] when root is available.
*/
fun configureRootCapture(width: Int, height: Int) {
val py = Python.getInstance()
val engine = py.getModule("ledgrab.core.capture_engines.root_screenrecord_engine")
engine.callAttr("configure", width, height)
rootEngine = engine
Log.i(TAG, "Root screenrecord engine configured: ${width}x${height}")
}
/**
* Start the LedGrab FastAPI server on a background thread.
*
* This blocks until [stopServer] is called, so it runs in its own thread.
*/
fun startServer(port: Int = 8080) {
if (running) {
Log.w(TAG, "Server already running")
return
}
running = true
val dataDir = context.filesDir.absolutePath
serverThread = Thread({
try {
Log.i(TAG, "Starting Python server (dataDir=$dataDir, port=$port)")
val py = Python.getInstance()
val entry = py.getModule("ledgrab.android_entry")
entry.callAttr("start_server", dataDir, port)
} catch (e: Exception) {
Log.e(TAG, "Python server error", e)
} finally {
running = false
}
}, "ledgrab-python-server")
serverThread?.start()
}
/**
* Signal the Python server to shut down gracefully.
*/
fun stopServer() {
if (!running) return
try {
val py = Python.getInstance()
val entry = py.getModule("ledgrab.android_entry")
entry.callAttr("stop_server")
} catch (e: Exception) {
Log.e(TAG, "Error stopping server", e)
}
serverThread?.join(10_000)
serverThread = null
running = false
Log.i(TAG, "Server stopped")
}
/**
* Push a captured RGBA frame to the Python MediaProjection engine.
*
* Called from [ScreenCapture] on the capture thread. The byte array
* crosses the JNI boundary — keep frames small (downscale to 480p
* before calling).
*/
fun pushFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
if (!running) return
val engine = mediaProjectionEngine ?: return
try {
engine.callAttr("push_frame", rgbaBytes, width, height)
} catch (e: Exception) {
Log.w(TAG, "Failed to push frame: ${e.message}")
}
}
/**
* Push a frame produced by the root `screenrecord` pipeline.
*
* Routed to a separate Python module so the two capture paths stay
* independently introspectable (engine priority, availability flags,
* dashboard diagnostics).
*/
fun pushRootFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
if (!running) return
val engine = rootEngine ?: return
try {
engine.callAttr("push_frame", rgbaBytes, width, height)
} catch (e: Exception) {
Log.w(TAG, "Failed to push root frame: ${e.message}")
}
}
val isRunning: Boolean get() = running
}
@@ -0,0 +1,136 @@
package com.ledgrab.android
import android.util.Log
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.concurrent.TimeUnit
/**
* Lightweight root-access utilities.
*
* Detection is two-phase:
* 1. Cheap check for a `su` binary in common paths (no process spawn).
* 2. On demand, a real `su -c id` execution that returns true only when
* the shell actually runs as UID 0. This triggers Magisk's grant
* dialog the first time, exactly like any other root request.
*
* The heavier check is cached after it succeeds so we don't ask Magisk
* repeatedly mid-session.
*/
object Root {
private const val TAG = "Root"
private val SU_PATHS = listOf(
"/system/bin/su",
"/system/xbin/su",
"/sbin/su",
"/su/bin/su",
"/debug_ramdisk/su",
"/vendor/bin/su",
)
@Volatile private var cachedGranted: Boolean? = null
/** Cheap probe: true if a known `su` binary exists. Doesn't run anything. */
@JvmStatic
fun looksRooted(): Boolean = SU_PATHS.any { java.io.File(it).exists() }
/**
* Actually exercise `su`. Triggers Magisk's grant dialog on first call.
* Returns true if the shell reports UID 0 within the timeout; false
* otherwise (no root, user denied, or timeout).
*/
@JvmStatic
fun requestGrant(timeoutSeconds: Long = 10): Boolean {
cachedGranted?.let { return it }
if (!looksRooted()) {
cachedGranted = false
return false
}
val granted = try {
// redirectErrorStream merges stderr into stdout so a single
// drain thread is enough — avoids the classic pipe-buffer
// deadlock where waitFor() blocks because stderr filled up.
val process = ProcessBuilder("su", "-c", "id")
.redirectErrorStream(true)
.start()
val outputBuilder = StringBuilder()
val drain = Thread({
try {
BufferedReader(InputStreamReader(process.inputStream)).use { r ->
val buf = CharArray(512)
while (true) {
val n = r.read(buf)
if (n < 0) break
synchronized(outputBuilder) { outputBuilder.append(buf, 0, n) }
}
}
} catch (_: Exception) {
// Process gone — drain ends.
}
}, "Root-su-drain").apply { isDaemon = true; start() }
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
if (!finished) {
process.destroyForcibly()
drain.join(500)
Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s")
false
} else {
drain.join(500)
val output = synchronized(outputBuilder) { outputBuilder.toString() }
if (process.exitValue() != 0) {
Log.w(TAG, "su -c id exited with ${process.exitValue()} output='${output.trim()}'")
false
} else {
val rooted = output.contains("uid=0")
Log.i(TAG, "su -c id → '${output.trim()}' → rooted=$rooted")
rooted
}
}
} catch (e: Exception) {
Log.w(TAG, "su invocation failed: ${e.message}")
false
}
cachedGranted = granted
return granted
}
/**
* Run an `su -c <cmd>` command. Returns true on exit-zero. Failure
* invalidates the cached grant so the next [requestGrant] re-checks
* (covers cases like Magisk grant being revoked mid-session).
*/
@JvmStatic
fun runAsRoot(cmd: String, timeoutSeconds: Long = 5): Boolean {
return try {
val process = ProcessBuilder("su", "-c", cmd)
.redirectErrorStream(true)
.start()
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
if (!finished) {
process.destroyForcibly()
cachedGranted = null
false
} else if (process.exitValue() != 0) {
cachedGranted = null
false
} else {
true
}
} catch (e: Exception) {
Log.w(TAG, "runAsRoot('$cmd') failed: ${e.message}")
cachedGranted = null
false
}
}
/** Forget the cached grant result — useful if Magisk permission was revoked. */
@JvmStatic
fun invalidateCache() {
cachedGranted = null
}
}
@@ -0,0 +1,273 @@
package com.ledgrab.android
import android.graphics.PixelFormat
import android.media.ImageReader
import android.media.MediaCodec
import android.media.MediaFormat
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import java.io.InputStream
import java.util.concurrent.atomic.AtomicInteger
/**
* Root-only screen capture backed by `/system/bin/screenrecord`.
*
* When the device is rooted (Magisk), we spawn ``su -c screenrecord
* --output-format=h264 …`` and read the encoder's H.264 bitstream from
* stdout. A MediaCodec decoder is configured to render into an
* ImageReader's Surface, and the ImageReader's `onImageAvailable`
* callback feeds RGBA bytes back to the Python pipeline — same sink as
* the MediaProjection path, just a different source.
*
* Why bother: MediaProjection forces Android to draw a persistent
* capture indicator overlay (unavoidable on stock Android 14+). The
* root path sidesteps that entirely because `screenrecord` runs as
* system UID through `su`. It also skips the one-time MediaProjection
* consent dialog, which matters on TV-only setups where keyboard input
* is awkward.
*/
class RootScreenrecord(
private val bridge: PythonBridge,
private val width: Int = 480,
private val height: Int = 270,
private val bitRate: Int = 4_000_000,
private val fps: Int = 30,
) {
companion object {
private const val TAG = "RootScreenrecord"
private const val MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC
private const val INPUT_CHUNK = 64 * 1024
}
@Volatile private var process: Process? = null
private var decoder: MediaCodec? = null
private var imageReader: ImageReader? = null
private var readerThread: HandlerThread? = null
private var inputThread: Thread? = null
private var outputThread: Thread? = null
@Volatile private var running = false
private val framesDeliveredCounter = AtomicInteger(0)
@Volatile private var stopped = false
/** Monotonic count of frames pushed to the Python bridge. */
val framesDelivered: Int get() = framesDeliveredCounter.get()
/** True once at least one frame has reached the Python bridge. */
val hasProducedFrame: Boolean get() = framesDelivered > 0
/**
* Start the capture pipeline. Returns false if `su`/`screenrecord`
* couldn't be launched at all; true doesn't guarantee frames will
* actually flow (the caller should monitor [hasProducedFrame] and
* be ready to fall back to MediaProjection on timeout).
*/
fun start(): Boolean {
if (running) return true
running = true
try {
imageReader = buildImageReader()
decoder = buildDecoder(imageReader!!)
process = spawnScreenrecord() ?: run {
stop()
return false
}
startInputPump(process!!.inputStream, decoder!!)
startOutputDrain(decoder!!)
Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)")
return true
} catch (e: Exception) {
Log.e(TAG, "Failed to start root capture", e)
stop()
return false
}
}
/** Stop everything and release resources. Idempotent. */
@Synchronized
fun stop() {
if (stopped) return
stopped = true
// Order matters: signal first so worker loops drop out, then
// stop the codec on the thread that created it (this one), then
// join workers BEFORE releasing the codec/ImageReader they may
// still be touching, then kill the external screenrecord process.
running = false
runCatching { decoder?.stop() }
inputThread?.interrupt()
outputThread?.interrupt()
runCatching { inputThread?.join(500) }
runCatching { outputThread?.join(500) }
inputThread = null
outputThread = null
// Best-effort: kill the screenrecord child before reaping `su`,
// otherwise screenrecord can outlive su as an orphan and keep
// the GPU encoder busy. Fire-and-forget; ignore failures.
runCatching { Root.runAsRoot("pkill -TERM screenrecord", timeoutSeconds = 2) }
runCatching { decoder?.release() }
decoder = null
runCatching { imageReader?.setOnImageAvailableListener(null, null) }
runCatching { imageReader?.close() }
imageReader = null
readerThread?.quitSafely()
runCatching { readerThread?.join(500) }
readerThread = null
runCatching { process?.destroy() }
process = null
Log.i(TAG, "Root capture pipeline stopped (frames delivered: ${framesDelivered})")
}
private fun buildImageReader(): ImageReader {
val thread = HandlerThread("RootReader").apply { start() }
readerThread = thread
val handler = Handler(thread.looper)
val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
reader.setOnImageAvailableListener({ r ->
val image = r.acquireLatestImage() ?: return@setOnImageAvailableListener
try {
val plane = image.planes[0]
val buffer = plane.buffer
val rowStride = plane.rowStride
val pixelStride = plane.pixelStride
val bytes = if (rowStride == width * pixelStride) {
ByteArray(buffer.remaining()).also { buffer.get(it) }
} else {
// Strip row padding — common when width isn't a multiple of 16.
val rowBytes = width * pixelStride
ByteArray(width * height * 4).also { out ->
for (row in 0 until height) {
buffer.position(row * rowStride)
buffer.get(out, row * rowBytes, rowBytes)
}
}
}
bridge.pushRootFrame(bytes, width, height)
framesDeliveredCounter.incrementAndGet()
} catch (e: Exception) {
Log.w(TAG, "Root frame delivery failed: ${e.message}")
} finally {
image.close()
}
}, handler)
return reader
}
private fun buildDecoder(reader: ImageReader): MediaCodec {
val format = MediaFormat.createVideoFormat(MIME_TYPE, width, height).apply {
setInteger(MediaFormat.KEY_FRAME_RATE, fps)
}
val codec = MediaCodec.createDecoderByType(MIME_TYPE)
codec.configure(format, reader.surface, null, 0)
codec.start()
return codec
}
private fun spawnScreenrecord(): Process? {
val cmd = buildString {
append("screenrecord")
append(" --output-format=h264")
append(" --size=${width}x$height")
append(" --bit-rate=$bitRate")
// Time limit 0 isn't supported; the largest accepted is 180s.
// We restart the process ourselves if it exits early.
append(" --time-limit=180")
append(" -")
}
return try {
Runtime.getRuntime().exec(arrayOf("su", "-c", cmd))
} catch (e: Exception) {
Log.e(TAG, "Failed to spawn `su -c screenrecord`: ${e.message}")
null
}
}
private fun startInputPump(initialStream: InputStream, codec: MediaCodec) {
inputThread = Thread({
val buf = ByteArray(INPUT_CHUNK)
var stream: InputStream = initialStream
try {
outer@ while (running) {
val n = try {
stream.read(buf)
} catch (e: Exception) {
if (!running) break
Log.w(TAG, "screenrecord read error: ${e.message}")
-1
}
if (n <= 0) {
if (!running) break
// screenrecord caps at --time-limit=180s. When it
// exits cleanly we respawn so capture survives
// long sessions instead of freezing after ~3min.
Log.i(TAG, "screenrecord EOF — respawning")
runCatching { process?.destroy() }
val next = spawnScreenrecord()
if (next == null) {
// Avoid a tight loop if `su` is suddenly unhappy.
try { Thread.sleep(500) } catch (_: InterruptedException) { break }
continue@outer
}
process = next
stream = next.inputStream
continue@outer
}
var offset = 0
while (offset < n && running) {
val index = codec.dequeueInputBuffer(50_000)
if (index < 0) continue
val inputBuffer = codec.getInputBuffer(index) ?: continue
inputBuffer.clear()
val chunk = minOf(n - offset, inputBuffer.capacity())
inputBuffer.put(buf, offset, chunk)
codec.queueInputBuffer(
index,
0,
chunk,
System.nanoTime() / 1_000,
0,
)
offset += chunk
}
}
} catch (_: InterruptedException) {
// Expected on stop()
} catch (e: Exception) {
Log.w(TAG, "Input pump error: ${e.message}")
}
}, "RootDecoderInput").also { it.start() }
}
private fun startOutputDrain(codec: MediaCodec) {
outputThread = Thread({
val info = MediaCodec.BufferInfo()
try {
while (running) {
val index = codec.dequeueOutputBuffer(info, 50_000)
if (index >= 0) {
// `true` = render to the configured surface (ImageReader),
// which triggers onImageAvailable on the reader's handler.
codec.releaseOutputBuffer(index, true)
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
Log.i(TAG, "Decoder reported EOS")
break
}
}
}
} catch (_: InterruptedException) {
// Expected on stop()
} catch (e: Exception) {
Log.w(TAG, "Output drain error: ${e.message}")
}
}, "RootDecoderOutput").also { it.start() }
}
}
@@ -0,0 +1,155 @@
package com.ledgrab.android
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.ImageReader
import android.media.projection.MediaProjection
import android.os.Handler
import android.os.HandlerThread
import android.util.DisplayMetrics
import android.util.Log
import java.nio.ByteBuffer
/**
* Captures the Android screen via MediaProjection and feeds frames
* to [PythonBridge].
*
* Frames are downscaled to [targetWidth] x [targetHeight] before
* crossing the JNI boundary to minimize overhead. For LED ambient
* lighting, even 480x270 contains far more data than needed.
*/
class ScreenCapture(
private val projection: MediaProjection,
private val metrics: DisplayMetrics,
private val bridge: PythonBridge,
private val targetWidth: Int = 480,
private val targetHeight: Int = 270,
private val targetFps: Int = 30,
private val onProjectionStopped: () -> Unit = {},
) {
companion object {
private const val TAG = "ScreenCapture"
private const val VIRTUAL_DISPLAY_NAME = "LedGrabCapture"
}
private var virtualDisplay: VirtualDisplay? = null
private var imageReader: ImageReader? = null
private var captureThread: HandlerThread? = null
private var captureHandler: Handler? = null
@Volatile private var running = false
private var lastFrameTimeMs = 0L
private val frameIntervalMs = 1000L / targetFps
/**
* Start capturing the screen.
*/
fun start() {
if (running) return
running = true
captureThread = HandlerThread("LedGrab-Capture").also { it.start() }
captureHandler = Handler(captureThread!!.looper)
// Android 14+ requires registering a callback before createVirtualDisplay
projection.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
Log.i(TAG, "MediaProjection stopped (external)")
stop()
// Notify the service so the foreground notification /
// Python server get torn down too — otherwise a stale
// "Running" notification lingers after the user taps
// Android's system Cast/Screen-capture stop banner.
onProjectionStopped()
}
}, captureHandler)
imageReader = ImageReader.newInstance(
targetWidth,
targetHeight,
PixelFormat.RGBA_8888,
2, // maxImages — double buffer
)
imageReader?.setOnImageAvailableListener({ reader ->
if (!running) return@setOnImageAvailableListener
val now = System.currentTimeMillis()
if (now - lastFrameTimeMs < frameIntervalMs) {
// Skip frame to maintain target FPS
reader.acquireLatestImage()?.close()
return@setOnImageAvailableListener
}
val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener
try {
val plane = image.planes[0]
val buffer = plane.buffer
val rowStride = plane.rowStride
val pixelStride = plane.pixelStride
// Handle row padding: rowStride may be > width * pixelStride
val rgbaBytes = if (rowStride == targetWidth * pixelStride) {
// No padding — direct copy
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
bytes
} else {
// Strip row padding
val rowBytes = targetWidth * pixelStride
val bytes = ByteArray(targetWidth * targetHeight * 4)
for (row in 0 until targetHeight) {
buffer.position(row * rowStride)
buffer.get(bytes, row * rowBytes, rowBytes)
}
bytes
}
bridge.pushFrame(rgbaBytes, targetWidth, targetHeight)
lastFrameTimeMs = now
} catch (e: Exception) {
Log.w(TAG, "Frame processing error: ${e.message}")
} finally {
image.close()
}
}, captureHandler)
virtualDisplay = projection.createVirtualDisplay(
VIRTUAL_DISPLAY_NAME,
targetWidth,
targetHeight,
metrics.densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader?.surface,
null,
captureHandler,
)
Log.i(TAG, "Screen capture started (${targetWidth}x${targetHeight} @ ${targetFps}fps)")
}
/**
* Stop capturing and release all resources.
*/
fun stop() {
running = false
// Order matters: detach the listener BEFORE releasing the
// VirtualDisplay so the handler can't be re-entered with stale
// resources, then quit & join the handler thread, only then
// close the ImageReader.
runCatching { imageReader?.setOnImageAvailableListener(null, null) }
runCatching { virtualDisplay?.release() }
virtualDisplay = null
captureThread?.quitSafely()
runCatching { captureThread?.join(500) }
captureThread = null
captureHandler = null
runCatching { imageReader?.close() }
imageReader = null
Log.i(TAG, "Screen capture stopped")
}
}
@@ -0,0 +1,288 @@
package com.ledgrab.android
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbManager
import android.os.Build
import android.util.Log
import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.driver.UsbSerialProber
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.TimeoutCancellationException
/**
* USB-serial bridge exposed to the Python server via Chaquopy.
*
* Uses the `usb-serial-for-android` library (mik3y) which ships drivers
* for the common USB-to-TTL chips (CH340, CP2102, FTDI, Prolific, and
* CDC-ACM) found on Arduino boards and Adalight/AmbiLED controllers.
*
* Python callers access the singleton instance via
* `UsbSerialBridge.INSTANCE.listDevices()` etc. — see
* `server/src/ledgrab/core/devices/android_serial_transport.py`.
*
* The bridge holds no Context of its own; [init] must be called once
* from [LedGrabApp.onCreate] to bind the application context.
*/
object UsbSerialBridge {
private const val TAG = "UsbSerialBridge"
private const val ACTION_USB_PERMISSION = "com.ledgrab.android.USB_PERMISSION"
@Volatile private var appContext: Context? = null
private val handleSeq = AtomicInteger(1)
private val openPorts = HashMap<Int, UsbSerialPort>()
private val initialized = AtomicBoolean(false)
private val pendingPermissions = ConcurrentHashMap<String, CompletableDeferred<Boolean>>()
/** Called once from [LedGrabApp.onCreate] so we can resolve services. */
@JvmStatic
fun init(context: Context) {
val app = context.applicationContext
appContext = app
// Idempotent: re-entrant init() must not double-register the
// receiver (which would leak listeners and double-fire callbacks).
if (!initialized.compareAndSet(false, true)) return
val filter = IntentFilter(ACTION_USB_PERMISSION)
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
val granted = intent.getBooleanExtra(
UsbManager.EXTRA_PERMISSION_GRANTED,
false,
)
val device = intent.getParcelableExtra<android.hardware.usb.UsbDevice>(
UsbManager.EXTRA_DEVICE,
)
Log.i(TAG, "USB permission broadcast: granted=$granted device=${device?.deviceName}")
device?.deviceName?.let { name ->
pendingPermissions.remove(name)?.complete(granted)
}
}
}
// Android 14 requires RECEIVER_NOT_EXPORTED for non-system broadcasts.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
app.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
app.registerReceiver(receiver, filter)
}
}
private fun ctx(): Context =
appContext ?: error("UsbSerialBridge.init() not called — app context unavailable")
private fun safeSerial(driver: UsbSerialDriver): String =
try {
driver.device.serialNumber ?: ""
} catch (_: SecurityException) {
// Reading the serial requires USB permission on API 29+.
""
}
/**
* Enumerate attached USB-serial devices.
*
* Each entry is `"VID|PID|serial|description"` with VID/PID as
* 4-char lowercase hex. Pipe is used as the separator so device
* descriptions containing colons (common on FTDI strings) don't
* confuse the Python parser.
*/
@JvmStatic
fun listDevices(): List<String> {
val manager = ctx().getSystemService(Context.USB_SERVICE) as UsbManager
val drivers = UsbSerialProber.getDefaultProber().findAllDrivers(manager)
return drivers.map { driver ->
val dev = driver.device
val vid = "%04x".format(dev.vendorId)
val pid = "%04x".format(dev.productId)
val serial = safeSerial(driver)
val description = buildString {
append(dev.manufacturerName ?: "USB")
val product = dev.productName
if (!product.isNullOrBlank()) {
append(' ')
append(product)
}
}.trim().ifEmpty { "USB $vid:$pid" }
"$vid|$pid|$serial|$description"
}
}
/**
* Open the first matching USB-serial device. Returns a non-negative
* opaque handle on success, -1 on failure (device not found, user
* denied permission, or driver error). Failures also trigger an
* async permission-request dialog when applicable — subsequent
* open() calls will succeed once the user grants.
*/
@JvmStatic
fun open(vendorId: Int, productId: Int, serial: String, baud: Int): Int {
val context = ctx()
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val drivers = UsbSerialProber.getDefaultProber().findAllDrivers(manager)
val driver = drivers.firstOrNull { d ->
val dev = d.device
dev.vendorId == vendorId &&
dev.productId == productId &&
(serial.isEmpty() || safeSerial(d) == serial)
}
if (driver == null) {
Log.w(TAG, "No matching device for $vendorId:$productId:$serial")
return -1
}
if (!manager.hasPermission(driver.device)) {
Log.w(TAG, "USB permission not yet granted for ${driver.device.deviceName}")
requestPermission(context, manager, driver)
return -1
}
val connection = manager.openDevice(driver.device)
if (connection == null) {
Log.w(TAG, "openDevice returned null for ${driver.device.deviceName}")
return -1
}
val port = driver.ports.firstOrNull()
if (port == null) {
connection.close()
Log.w(TAG, "Driver reports no ports for ${driver.device.deviceName}")
return -1
}
try {
port.open(connection)
port.setParameters(
baud,
8,
UsbSerialPort.STOPBITS_1,
UsbSerialPort.PARITY_NONE,
)
} catch (e: Exception) {
Log.e(TAG, "Failed to configure serial port", e)
runCatching { port.close() }
return -1
}
val handle = handleSeq.getAndIncrement()
synchronized(openPorts) { openPorts[handle] = port }
Log.i(
TAG,
"Opened USB serial ${driver.device.deviceName} baud=$baud handle=$handle",
)
return handle
}
/** Write bytes to the previously-opened handle. Throws if invalid. */
@JvmStatic
fun write(handle: Int, data: ByteArray) {
val port = synchronized(openPorts) { openPorts[handle] }
?: throw IllegalStateException("Invalid handle $handle")
// 1s write timeout matches the old pyserial `timeout=1` behavior.
port.write(data, 1_000)
}
/** Close a previously-opened handle. Silently ignores unknown handles. */
@JvmStatic
fun close(handle: Int) {
val port = synchronized(openPorts) { openPorts.remove(handle) } ?: return
runCatching { port.close() }
.onFailure { Log.w(TAG, "close($handle): ${it.message}") }
}
/**
* Block until the user grants (or denies) USB permission for the
* device with [deviceName] (e.g. "/dev/bus/usb/001/004"). Returns
* true if granted within [timeoutMs], false otherwise. Safe to call
* from a Python thread via Chaquopy.
*/
@JvmStatic
@JvmOverloads
fun requestPermissionBlocking(deviceName: String, timeoutMs: Long = 15_000L): Boolean {
val context = ctx()
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val driver = UsbSerialProber.getDefaultProber().findAllDrivers(manager)
.firstOrNull { it.device.deviceName == deviceName }
?: return false
if (manager.hasPermission(driver.device)) return true
// Coalesce concurrent requests for the same device — only the
// first caller actually fires the system dialog.
val deferred = pendingPermissions.computeIfAbsent(deviceName) {
CompletableDeferred<Boolean>().also {
requestPermission(context, manager, driver)
}
}
return try {
runBlocking {
withTimeout(timeoutMs) { deferred.await() }
}
} catch (_: TimeoutCancellationException) {
pendingPermissions.remove(deviceName)
Log.w(TAG, "Permission request timed out for $deviceName")
false
} catch (e: Exception) {
pendingPermissions.remove(deviceName)
Log.w(TAG, "Permission request failed for $deviceName: ${e.message}")
false
}
}
/**
* Like [open] but blocks for permission first. Use this from Python
* instead of relying on the open()/retry pattern.
*/
@JvmStatic
@JvmOverloads
fun openWithPermission(
vendorId: Int,
productId: Int,
serial: String,
baud: Int,
timeoutMs: Long = 15_000L,
): Int {
val context = ctx()
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val driver = UsbSerialProber.getDefaultProber().findAllDrivers(manager)
.firstOrNull { d ->
val dev = d.device
dev.vendorId == vendorId &&
dev.productId == productId &&
(serial.isEmpty() || safeSerial(d) == serial)
} ?: return -1
if (!manager.hasPermission(driver.device)) {
val granted = requestPermissionBlocking(driver.device.deviceName, timeoutMs)
if (!granted) return -1
}
return open(vendorId, productId, serial, baud)
}
private fun requestPermission(
context: Context,
manager: UsbManager,
driver: UsbSerialDriver,
) {
val flags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE
} else {
0
}
val intent = Intent(ACTION_USB_PERMISSION).apply {
setPackage(context.packageName)
}
val pending = PendingIntent.getBroadcast(context, 0, intent, flags)
manager.requestPermission(driver.device, pending)
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true" android:color="#ffffff" />
<item android:color="@color/purple_accent" />
</selector>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<layer-list>
<item android:left="-6dp" android:top="-6dp" android:right="-6dp" android:bottom="-6dp">
<shape android:shape="rectangle">
<solid android:color="#4064ffda" />
<corners android:radius="44dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#64ffda" />
<corners android:radius="36dp" />
</shape>
</item>
</layer-list>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="#3dccb0" />
<corners android:radius="36dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/teal_accent" />
<corners android:radius="36dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<layer-list>
<item android:left="-6dp" android:top="-6dp" android:right="-6dp" android:bottom="-6dp">
<shape android:shape="rectangle">
<solid android:color="#40bb86fc" />
<corners android:radius="44dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#7c4dff" />
<corners android:radius="36dp" />
</shape>
</item>
</layer-list>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="#9966d4" />
<corners android:radius="36dp" />
<stroke android:width="2dp" android:color="@color/purple_accent" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#1Abb86fc" />
<corners android:radius="36dp" />
<stroke android:width="2dp" android:color="@color/purple_accent" />
</shape>
</item>
</selector>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<color android:color="@color/bg_navy" />
</item>
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:gradientRadius="900dp"
android:centerX="0.5"
android:centerY="-0.2"
android:startColor="#1A64ffda"
android:endColor="#000d1117" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:gradientRadius="600dp"
android:centerX="1.1"
android:centerY="1.2"
android:startColor="#12bb86fc"
android:endColor="#000d1117" />
</shape>
</item>
</layer-list>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:left="-8dp" android:top="-8dp" android:right="-8dp" android:bottom="-8dp">
<shape android:shape="rectangle">
<solid android:color="#2064ffda" />
<corners android:radius="24dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#ffffff" />
<corners android:radius="16dp" />
<stroke android:width="3dp" android:color="@color/teal_accent" />
</shape>
</item>
</layer-list>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/green_status" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/bg_surface_elevated" />
<corners android:radius="12dp" />
<stroke android:width="1dp" android:color="#2264ffda" />
</shape>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Background circle -->
<path
android:fillColor="#0d1117"
android:pathData="M54,54m-50,0a50,50 0,1 1,100 0a50,50 0,1 1,-100 0" />
<!-- Border ring -->
<path
android:strokeColor="#2264ffda"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:pathData="M54,54m-48,0a48,48 0,1 1,96 0a48,48 0,1 1,-96 0" />
<!-- TV body -->
<path
android:fillColor="#1c2333"
android:pathData="M26,32 L82,32 Q86,32 86,36 L86,68 Q86,72 82,72 L26,72 Q22,72 22,68 L22,36 Q22,32 26,32 Z" />
<!-- TV screen -->
<path
android:fillColor="#161b22"
android:pathData="M28,35 L80,35 Q82,35 82,37 L82,66 Q82,68 80,68 L28,68 Q26,68 26,66 L26,37 Q26,35 28,35 Z" />
<!-- LED glow - top (teal) -->
<path
android:fillColor="#64ffda"
android:fillAlpha="0.7"
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
<!-- LED glow - left (purple) -->
<path
android:fillColor="#bb86fc"
android:fillAlpha="0.6"
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
<!-- LED glow - right (red) -->
<path
android:fillColor="#ff6b6b"
android:fillAlpha="0.6"
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
<!-- LED glow - bottom (yellow) -->
<path
android:fillColor="#ffd93d"
android:fillAlpha="0.6"
android:pathData="M30,74 L78,74 L78,76 L30,76 Z" />
<!-- TV stand -->
<path
android:fillColor="#1c2333"
android:pathData="M44,72 L44,78 L64,78 L64,72" />
<path
android:fillColor="#1c2333"
android:pathData="M38,78 L70,78 L70,80 L38,80 Z" />
</vector>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Adaptive icon foreground: TV with LED glow strips.
Centered in the 108dp safe zone (inner 72dp is guaranteed visible). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- TV body -->
<path
android:fillColor="#1c2333"
android:pathData="M26,32 L82,32 Q86,32 86,36 L86,68 Q86,72 82,72 L26,72 Q22,72 22,68 L22,36 Q22,32 26,32 Z" />
<!-- TV screen -->
<path
android:fillColor="#161b22"
android:pathData="M28,35 L80,35 Q82,35 82,37 L82,66 Q82,68 80,68 L28,68 Q26,68 26,66 L26,37 Q26,35 28,35 Z" />
<!-- LED glow - top (teal) -->
<path
android:fillColor="#64ffda"
android:fillAlpha="0.7"
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
<!-- LED glow - left (purple) -->
<path
android:fillColor="#bb86fc"
android:fillAlpha="0.6"
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
<!-- LED glow - right (red) -->
<path
android:fillColor="#ff6b6b"
android:fillAlpha="0.6"
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
<!-- LED glow - bottom (yellow) -->
<path
android:fillColor="#ffd93d"
android:fillAlpha="0.6"
android:pathData="M30,74 L78,74 L78,76 L30,76 Z" />
<!-- TV stand -->
<path
android:fillColor="#1c2333"
android:pathData="M44,72 L44,78 L64,78 L64,72" />
<path
android:fillColor="#1c2333"
android:pathData="M38,78 L70,78 L70,80 L38,80 Z" />
</vector>
@@ -0,0 +1,191 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_main">
<!-- STOPPED STATE -->
<LinearLayout
android:id="@+id/stopped_panel"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:paddingStart="160dp"
android:paddingEnd="160dp">
<ImageView
android:layout_width="72dp"
android:layout_height="72dp"
android:src="@drawable/ic_launcher"
android:contentDescription="@null"
android:layout_marginBottom="24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textColor="@color/teal_accent"
android:textSize="64sp"
android:textStyle="bold"
android:letterSpacing="0.08"
android:layout_marginBottom="12dp"
android:fontFamily="sans-serif-light" />
<TextView
android:id="@+id/status_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tagline"
android:textColor="@color/text_secondary"
android:textSize="28sp"
android:layout_marginBottom="64dp" />
<Button
android:id="@+id/toggle_button"
style="@style/Widget.LedGrab.Button.Primary"
android:layout_width="320dp"
android:layout_height="72dp"
android:text="@string/btn_start"
android:textSize="22sp"
android:focusable="true"
android:focusableInTouchMode="true" />
<CheckBox
android:id="@+id/autostart_check"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="@string/autostart_label"
android:textColor="@color/text_secondary"
android:textSize="20sp"
android:buttonTint="@color/teal_accent"
android:focusable="true"
android:focusableInTouchMode="true" />
</LinearLayout>
<!-- Version at bottom -->
<TextView
android:id="@+id/version_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="32dp"
android:textColor="@color/text_hint"
android:textSize="18sp"
tools:text="v0.1.0" />
<!-- RUNNING STATE -->
<LinearLayout
android:id="@+id/running_panel"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="120dp"
android:paddingEnd="120dp"
android:paddingTop="80dp"
android:paddingBottom="80dp"
android:visibility="gone">
<!-- Left: status + URL + stop -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="start|center_vertical"
android:paddingEnd="64dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="32dp">
<View
android:layout_width="18dp"
android:layout_height="18dp"
android:background="@drawable/bg_status_dot"
android:layout_marginEnd="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/status_running"
android:textColor="@color/green_status"
android:textSize="28sp"
android:textStyle="bold"
android:letterSpacing="0.05" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/label_web_ui"
android:textColor="@color/text_secondary"
android:textSize="22sp"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/url_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/teal_accent"
android:textSize="30sp"
android:maxLines="1"
android:textStyle="bold"
android:background="@drawable/bg_url_chip"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:layout_marginBottom="56dp"
tools:text="http://192.168.1.5:8080" />
<Button
android:id="@+id/stop_button_running"
style="@style/Widget.LedGrab.Button.Secondary"
android:layout_width="240dp"
android:layout_height="64dp"
android:text="@string/btn_stop"
android:textSize="20sp"
android:focusable="true"
android:focusableInTouchMode="true" />
</LinearLayout>
<!-- Right: QR code -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_qr_container"
android:padding="20dp"
android:layout_marginBottom="20dp">
<ImageView
android:id="@+id/qr_image"
android:layout_width="280dp"
android:layout_height="280dp"
android:contentDescription="@string/qr_description"
android:scaleType="fitXY" />
</FrameLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/scan_to_configure"
android:textColor="@color/text_secondary"
android:textSize="22sp"
android:gravity="center" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg_navy" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">LedGrab</string>
<string name="tagline">Фоновая подсветка для телевизора</string>
<string name="btn_start">Начать захват</string>
<string name="btn_stop">Стоп</string>
<string name="status_running">Работает</string>
<string name="label_web_ui">Адрес веб-интерфейса</string>
<string name="scan_to_configure">Сканируйте для настройки</string>
<string name="qr_description">QR-код для веб-интерфейса</string>
<string name="version_prefix">v%1$s</string>
<string name="autostart_label">Запускать при загрузке (только с root)</string>
<string name="autostart_unavailable">Запуск при загрузке — недоступно (нужен root)</string>
</resources>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">LedGrab</string>
<string name="tagline">电视氛围灯光</string>
<string name="btn_start">开始捕获</string>
<string name="btn_stop">停止</string>
<string name="status_running">运行中</string>
<string name="label_web_ui">Web界面地址</string>
<string name="scan_to_configure">扫码配置</string>
<string name="qr_description">Web界面二维码</string>
<string name="version_prefix">v%1$s</string>
<string name="autostart_label">开机自启(仅限 root)</string>
<string name="autostart_unavailable">开机自启 — 不可用(需要 root)</string>
</resources>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="bg_navy">#0d1117</color>
<color name="bg_navy_mid">#111827</color>
<color name="bg_surface">#161b22</color>
<color name="bg_surface_elevated">#1c2333</color>
<color name="teal_accent">#64ffda</color>
<color name="teal_accent_dim">#3399aa</color>
<color name="teal_accent_alpha30">#4D64ffda</color>
<color name="teal_accent_alpha15">#2664ffda</color>
<color name="purple_accent">#bb86fc</color>
<color name="purple_accent_dim">#7c4dff</color>
<color name="purple_accent_alpha30">#4Dbb86fc</color>
<color name="purple_accent_alpha15">#26bb86fc</color>
<color name="green_status">#4caf50</color>
<color name="green_status_dim">#1b5e20</color>
<color name="text_primary">#e6edf3</color>
<color name="text_secondary">#8b949e</color>
<color name="text_hint">#484f58</color>
</resources>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">LedGrab</string>
<string name="tagline">Ambient lighting for your TV</string>
<string name="btn_start">Start Capture</string>
<string name="btn_stop">Stop</string>
<string name="status_running">Running</string>
<string name="label_web_ui">Web UI address</string>
<string name="scan_to_configure">Scan to configure</string>
<string name="qr_description">QR code for web UI</string>
<string name="version_prefix">v%1$s</string>
<string name="autostart_label">Start on boot (root only)</string>
<string name="autostart_unavailable">Start on boot — unavailable (root required)</string>
</resources>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.LedGrab" parent="@style/Theme.Leanback">
<item name="android:windowBackground">@color/bg_navy</item>
<item name="android:colorBackground">@color/bg_navy</item>
<item name="android:windowNoTitle">true</item>
<item name="android:textColorPrimary">@color/text_primary</item>
<item name="android:textColorSecondary">@color/text_secondary</item>
<item name="android:textColorHint">@color/text_hint</item>
<item name="android:colorAccent">@color/teal_accent</item>
<item name="android:colorControlHighlight">@color/teal_accent_dim</item>
<item name="android:colorControlActivated">@color/teal_accent</item>
</style>
<style name="Widget.LedGrab.Button.Primary" parent="@android:style/Widget.Button">
<item name="android:background">@drawable/bg_button_primary</item>
<item name="android:textColor">@color/bg_navy</item>
<item name="android:textStyle">bold</item>
<item name="android:focusable">true</item>
<item name="android:stateListAnimator">@null</item>
</style>
<style name="Widget.LedGrab.Button.Secondary" parent="@android:style/Widget.Button">
<item name="android:background">@drawable/bg_button_secondary</item>
<item name="android:textColor">@color/btn_secondary_text</item>
<item name="android:textStyle">bold</item>
<item name="android:focusable">true</item>
<item name="android:stateListAnimator">@null</item>
</style>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
LedGrab communicates with WLED controllers, Home Assistant, and MQTT
brokers on the local network via plain HTTP/UDP. Cleartext traffic
must be allowed for these connections to work on Android 9+.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
@@ -0,0 +1,198 @@
#!/usr/bin/env bash
#
# Cross-compile pydantic-core for Android across all three ABIs:
# arm64-v8a (primary — real TV hardware)
# x86_64 (modern emulators)
# x86 (legacy emulators)
#
# Outputs wheels into android/wheels/. Wheels are linked against the real
# libpython3.11.so shipped by Chaquopy (stub .so does NOT work — see
# memory/project_android_app.md for the incident notes).
#
# Prerequisites (on host):
# - Rust + cargo (rustup) with targets: aarch64/x86_64/i686-linux-android
# - Android NDK (ANDROID_NDK_HOME, or installed at Sdk/ndk/*)
# - Python 3.11 (matches Chaquopy's embedded version)
# - maturin (pip install maturin)
#
# Rebuild cadence: whenever PYDANTIC_CORE_VERSION changes, or pydantic's
# core dependency version changes.
#
# Usage:
# ./build-pydantic-core.sh # build all three ABIs
# ./build-pydantic-core.sh arm64 # build a single ABI
# ./build-pydantic-core.sh arm64 x86_64 # build a subset
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ANDROID_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
WHEELS_DIR="$ANDROID_DIR/wheels"
BUILD_DIR="$ANDROID_DIR/.build-cache"
PYDANTIC_CORE_VERSION="2.46.0"
API_LEVEL=24
PY_VERSION="3.11"
# Resolve a concrete python3.11 interpreter path (maturin needs it callable).
# Windows Git Bash doesn't ship `python3.11` on PATH; fall back to `py -3.11`.
if [ -z "${PYTHON_BIN:-}" ]; then
# Prefer `py -3.11` on Windows (Git Bash's `python3.11` resolves to a
# non-functional MSStore stub). Fall back to `python3.11` on Linux/macOS.
if command -v py >/dev/null 2>&1 && py -3.11 -c '' 2>/dev/null; then
PYTHON_BIN="$(py -3.11 -c 'import sys; print(sys.executable)' 2>/dev/null | tr -d '\r')"
elif command -v python3.11 >/dev/null 2>&1 && python3.11 -c '' 2>/dev/null; then
PYTHON_BIN="$(command -v python3.11)"
fi
fi
[ -n "${PYTHON_BIN:-}" ] || { echo "ERROR: python3.11 not found; set PYTHON_BIN"; exit 1; }
echo "Python: $PYTHON_BIN"
mkdir -p "$WHEELS_DIR"
# ── Find Android NDK ────────────────────────────────────────────────
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
for candidate in \
"$HOME/Library/Android/sdk/ndk"/* \
"$HOME/Android/Sdk/ndk"/* \
"${LOCALAPPDATA:-}/Android/Sdk/ndk"/* \
"/c/Users/$(whoami)/AppData/Local/Android/Sdk/ndk"/* \
"/usr/local/lib/android/sdk/ndk"/* \
"${ANDROID_SDK_ROOT:-}/ndk"/*; do
if [ -d "$candidate" ]; then
ANDROID_NDK_HOME="$candidate"
break
fi
done
fi
[ -n "${ANDROID_NDK_HOME:-}" ] || { echo "ERROR: NDK not found; set ANDROID_NDK_HOME"; exit 1; }
echo "NDK: $ANDROID_NDK_HOME"
case "$(uname -s)" in
Linux*) HOST_TAG="linux-x86_64" ;;
Darwin*) HOST_TAG="darwin-x86_64" ;;
MINGW*|MSYS*|CYGWIN*) HOST_TAG="windows-x86_64" ;;
*) echo "Unsupported host OS"; exit 1 ;;
esac
TOOLCHAIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/$HOST_TAG"
READELF="$TOOLCHAIN/bin/llvm-readelf"
# ── Prepare source tree ─────────────────────────────────────────────
SRC_DIR="$BUILD_DIR/pydantic_core-$PYDANTIC_CORE_VERSION"
if [ ! -d "$SRC_DIR" ]; then
echo "Downloading pydantic_core $PYDANTIC_CORE_VERSION sdist..."
mkdir -p "$BUILD_DIR"
SDIST="$BUILD_DIR/pydantic_core-$PYDANTIC_CORE_VERSION.tar.gz"
[ -f "$SDIST" ] || curl -sSL --retry 3 -o "$SDIST" \
"https://files.pythonhosted.org/packages/source/p/pydantic_core/pydantic_core-$PYDANTIC_CORE_VERSION.tar.gz"
tar -xzf "$SDIST" -C "$BUILD_DIR"
fi
# ── ABI table ───────────────────────────────────────────────────────
# Columns: short_name rust_target clang_prefix sysconfig_dir
ABI_TABLE=(
"arm64 aarch64-linux-android aarch64-linux-android${API_LEVEL} cross-sysconfig"
"x86_64 x86_64-linux-android x86_64-linux-android${API_LEVEL} cross-sysconfig-x86_64"
"x86 i686-linux-android i686-linux-android${API_LEVEL} cross-sysconfig-x86"
)
declare -A ABI_TAG_MAP=(
[arm64]="arm64_v8a"
[x86_64]="x86_64"
[x86]="x86"
)
# ── Select which ABIs to build ──────────────────────────────────────
SELECTED=("$@")
if [ ${#SELECTED[@]} -eq 0 ]; then
SELECTED=(arm64 x86_64 x86)
fi
# ── Ensure rust targets are installed ───────────────────────────────
for name in "${SELECTED[@]}"; do
for row in "${ABI_TABLE[@]}"; do
read -r sname rtarget _ _ <<<"$row"
if [ "$sname" = "$name" ]; then
rustup target add "$rtarget" >/dev/null 2>&1 || true
fi
done
done
# ── Build loop ──────────────────────────────────────────────────────
cd "$SRC_DIR"
for name in "${SELECTED[@]}"; do
MATCHED=""
for row in "${ABI_TABLE[@]}"; do
read -r sname rtarget cprefix sysdir <<<"$row"
if [ "$sname" = "$name" ]; then
MATCHED="1"
break
fi
done
[ -n "$MATCHED" ] || { echo "Unknown ABI: $name"; exit 1; }
CROSS_LIB_DIR="$BUILD_DIR/$sysdir"
[ -f "$CROSS_LIB_DIR/libpython3.11.so" ] || {
echo "ERROR: missing $CROSS_LIB_DIR/libpython3.11.so (extract from a Chaquopy APK)"
exit 1
}
# On Windows hosts the clang wrapper is a .cmd batch file; cargo/rustc
# can't exec it as a linker directly (os error 193). Use the .cmd path
# explicitly so CreateProcess picks up cmd.exe as interpreter.
CC_BIN="$TOOLCHAIN/bin/${cprefix}-clang"
if [ "$HOST_TAG" = "windows-x86_64" ] && [ -f "${CC_BIN}.cmd" ]; then
CC_BIN="${CC_BIN}.cmd"
fi
AR_BIN="$TOOLCHAIN/bin/llvm-ar"
[ "$HOST_TAG" = "windows-x86_64" ] && [ -f "${AR_BIN}.exe" ] && AR_BIN="${AR_BIN}.exe"
[ -f "$CC_BIN" ] || { echo "ERROR: $CC_BIN not found"; exit 1; }
# Normalize rust target name for env var (cargo wants UPPER_WITH_UNDERSCORES)
TARGET_ENV=$(echo "$rtarget" | tr 'a-z-' 'A-Z_')
echo ""
echo "════════════════════════════════════════════════════════════"
echo " Building pydantic_core $PYDANTIC_CORE_VERSION for $name ($rtarget)"
echo "════════════════════════════════════════════════════════════"
env \
CARGO_TARGET_DIR="$SRC_DIR/target" \
"CC_${rtarget//-/_}=$CC_BIN" \
"AR_${rtarget//-/_}=$AR_BIN" \
"CARGO_TARGET_${TARGET_ENV}_LINKER=$CC_BIN" \
PYO3_CROSS=1 \
PYO3_CROSS_LIB_DIR="$CROSS_LIB_DIR" \
PYO3_CROSS_PYTHON_VERSION="$PY_VERSION" \
RUSTFLAGS="-C link-arg=-Wl,--no-as-needed -C link-arg=-lpython3.11 -C link-arg=-L$CROSS_LIB_DIR" \
maturin build \
--release \
--target "$rtarget" \
--interpreter "$PYTHON_BIN" \
--out "$WHEELS_DIR" \
--compatibility linux
# maturin writes the wheel with the correct android_<api>_<abi> platform
# tag directly (the sysconfigdata provides MULTIARCH). Find + verify.
ABI_TAG="${ABI_TAG_MAP[$name]}"
OUT_WHL="$WHEELS_DIR/pydantic_core-$PYDANTIC_CORE_VERSION-cp311-cp311-android_${API_LEVEL}_${ABI_TAG}.whl"
[ -f "$OUT_WHL" ] || { echo "ERROR: expected wheel not found: $OUT_WHL"; exit 1; }
TMP=$(mktemp -d)
unzip -qo "$OUT_WHL" -d "$TMP" "pydantic_core/*.so"
SO=$(find "$TMP" -name "*.so" | head -1)
if "$READELF" -d "$SO" | grep -q "libpython3.11.so"; then
echo " NEEDED OK: libpython3.11.so present in $(basename "$OUT_WHL")"
else
echo " ERROR: libpython3.11.so NOT in NEEDED — wheel will crash at import"
"$READELF" -d "$SO" | grep NEEDED
rm -rf "$TMP"
exit 1
fi
rm -rf "$TMP"
done
echo ""
echo "=== Build complete ==="
ls -lh "$WHEELS_DIR"/pydantic_core-*.whl
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
#
# Set up the cross-compilation environment for building Python native
# extensions targeting Android ARM64.
#
# This script:
# 1. Verifies Android NDK is installed
# 2. Installs the Rust aarch64-linux-android target
# 3. Installs maturin (Python wheel builder for Rust extensions)
# 4. Runs a quick test compile to verify the toolchain works
#
set -euo pipefail
echo "=== LedGrab Android NDK Setup ==="
# ── Check prerequisites ─────────────────────────────────────────────
if ! command -v rustc &>/dev/null; then
echo "ERROR: Rust is not installed."
echo "Install from: https://rustup.rs/"
exit 1
fi
echo "Rust: $(rustc --version)"
if ! command -v cargo &>/dev/null; then
echo "ERROR: Cargo is not installed."
exit 1
fi
echo "Cargo: $(cargo --version)"
if ! command -v python3.11 &>/dev/null && ! command -v python3 &>/dev/null; then
echo "WARNING: Python 3.11 not found. Needed for maturin builds."
fi
# ── Install Rust target ─────────────────────────────────────────────
echo ""
echo "Installing Rust Android target..."
rustup target add aarch64-linux-android
echo "Installed targets:"
rustup target list --installed | grep android
# ── Install maturin ─────────────────────────────────────────────────
echo ""
echo "Installing maturin..."
pip install maturin 2>/dev/null || pip3 install maturin 2>/dev/null || {
echo "WARNING: Could not install maturin. Install manually: pip install maturin"
}
if command -v maturin &>/dev/null; then
echo "maturin: $(maturin --version)"
fi
# ── Verify NDK ──────────────────────────────────────────────────────
echo ""
if [ -n "${ANDROID_NDK_HOME:-}" ]; then
echo "ANDROID_NDK_HOME: $ANDROID_NDK_HOME"
else
echo "ANDROID_NDK_HOME is not set."
echo "Set it to your NDK installation path, e.g.:"
echo " export ANDROID_NDK_HOME=\$HOME/Android/Sdk/ndk/26.1.10909125"
echo ""
echo "Or install NDK via Android Studio:"
echo " SDK Manager → SDK Tools → NDK (Side by side)"
fi
echo ""
echo "=== Setup complete ==="
echo ""
echo "Next steps:"
echo " 1. Ensure ANDROID_NDK_HOME is set"
echo " 2. Run: ./build-pydantic-core.sh"
+5
View File
@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.9.0" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("com.chaquo.python") version "17.0.0" apply false
}
+7
View File
@@ -0,0 +1,7 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
org.gradle.parallel=false
org.gradle.configuration-cache=false
org.gradle.unsafe.isolated-projects=false
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+252
View File
@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+20
View File
@@ -0,0 +1,20 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
// usb-serial-for-android (mik3y) is distributed via JitPack
maven { url = uri("https://jitpack.io") }
}
}
rootProject.name = "LedGrab"
include(":app")
+283
View File
@@ -0,0 +1,283 @@
#!/usr/bin/env bash
#
# Shared build functions for LedGrab distribution packaging.
# Sourced by build-dist.sh (Linux) and build-dist-windows.sh (Windows).
#
# Expected variables set by the caller before sourcing:
# SCRIPT_DIR, BUILD_DIR, DIST_DIR, SERVER_DIR, APP_DIR
# ── Version detection ────────────────────────────────────────
detect_version() {
# Usage: detect_version [explicit_version]
local version="${1:-}"
if [ -z "$version" ]; then
version=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$version" ]; then
version="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
fi
if [ -z "$version" ]; then
version=$(grep -oP '^version\s*=\s*"\K[^"]+' "$SERVER_DIR/pyproject.toml" 2>/dev/null || echo "0.0.0")
fi
VERSION_CLEAN="${version#v}"
# Normalize non-PEP440 version labels (e.g. "dev", "nightly", "snapshot")
# to a valid PEP440 dev release. Without this, pip/setuptools rejects the
# pyproject.toml with: `project.version` must be pep440.
if ! [[ "$VERSION_CLEAN" =~ ^[0-9]+(\.[0-9]+)*((a|b|rc|\.dev|\.post)[0-9]+)*(\+[a-zA-Z0-9.]+)?$ ]]; then
echo " Warning: '$VERSION_CLEAN' is not PEP440-compliant, using 0.0.0.dev0"
VERSION_CLEAN="0.0.0.dev0"
fi
# Stamp the resolved version into pyproject.toml so that
# importlib.metadata reads the correct value at runtime.
sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" "$SERVER_DIR/pyproject.toml"
}
# ── Clean previous build ─────────────────────────────────────
clean_dist() {
if [ -d "$DIST_DIR" ]; then
echo " Cleaning previous build..."
rm -rf "$DIST_DIR"
fi
mkdir -p "$DIST_DIR"
}
# ── Build frontend ───────────────────────────────────────────
build_frontend() {
echo " Building frontend bundle..."
(cd "$SERVER_DIR" && npm ci --loglevel error && npm run build) 2>&1 | {
grep -v 'RemoteException' || true
}
}
# ── Copy application files ───────────────────────────────────
copy_app_files() {
echo " Copying application files..."
mkdir -p "$APP_DIR"
cp -r "$SERVER_DIR/src" "$APP_DIR/src"
cp -r "$SERVER_DIR/config" "$APP_DIR/config"
mkdir -p "$DIST_DIR/data" "$DIST_DIR/logs"
# Clean up source maps and __pycache__
find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true
find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
# Patch the fallback version in the bundled __init__.py. Bundled installs
# strip ledgrab-*.dist-info from site-packages, so importlib.metadata
# falls back to this literal at runtime — and a stale literal is what
# silently shipped v0.4.2 reporting "0.3.0" in the WebUI.
local bundled_init="$APP_DIR/src/ledgrab/__init__.py"
if [ -f "$bundled_init" ] && [ -n "${VERSION_CLEAN:-}" ]; then
sed -i "s/_FALLBACK_VERSION = \"[^\"]*\"/_FALLBACK_VERSION = \"${VERSION_CLEAN}\"/" "$bundled_init"
echo " Patched _FALLBACK_VERSION -> ${VERSION_CLEAN}"
fi
}
# ── Site-packages cleanup ────────────────────────────────────
#
# Strips tests, type stubs, unused submodules, and debug symbols
# from the installed site-packages directory.
#
# Args:
# $1 — path to site-packages directory
# $2 — native extension suffix: "pyd" (Windows) or "so" (Linux)
# $3 — native lib suffix for OpenCV ffmpeg: "dll" or "so"
cleanup_site_packages() {
local sp_dir="$1"
local ext_suffix="${2:-so}"
local lib_suffix="${3:-so}"
echo " Cleaning up site-packages to reduce size..."
# ── Generic cleanup ──────────────────────────────────────
find "$sp_dir" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -type d -name test -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -name "*.pyi" -delete 2>/dev/null || true
# ── pip / setuptools (not needed at runtime) ─────────────
rm -rf "$sp_dir"/pip "$sp_dir"/pip-* 2>/dev/null || true
rm -rf "$sp_dir"/setuptools "$sp_dir"/setuptools-* "$sp_dir"/pkg_resources 2>/dev/null || true
rm -rf "$sp_dir"/_distutils_hack 2>/dev/null || true
# ── OpenCV ───────────────────────────────────────────────
local cv2_dir="$sp_dir/cv2"
if [ -d "$cv2_dir" ]; then
# Remove ffmpeg (28 MB on Windows), Haar cascades, dev files
rm -f "$cv2_dir"/opencv_videoio_ffmpeg*."$lib_suffix" 2>/dev/null || true
rm -rf "$cv2_dir/data" "$cv2_dir/gapi" "$cv2_dir/misc" "$cv2_dir/utils" 2>/dev/null || true
rm -rf "$cv2_dir/typing_stubs" "$cv2_dir/typing" 2>/dev/null || true
fi
# ── NumPy ────────────────────────────────────────────────
# Only strip modules that are safely unused by numpy's own import chain.
# DO NOT strip: lib, linalg, ma, matrixlib — numpy.__init__ imports them
# transitively (e.g. matrixlib → defmatrix → linalg), so removing any of
# these breaks `import numpy` itself, cascading into every downstream
# module. Learned the hard way in the v0.0.0.dev0 Windows build.
for mod in polynomial distutils f2py typing _pyinstaller; do
rm -rf "$sp_dir/numpy/$mod" 2>/dev/null || true
done
rm -rf "$sp_dir/numpy/tests" "$sp_dir/numpy/*/tests" 2>/dev/null || true
# ── Pillow (only used for system tray icon) ──────────────
rm -rf "$sp_dir/PIL/tests" 2>/dev/null || true
# Remove unused image format plugins (keep JPEG, PNG, ICO, BMP)
for plugin in Eps Gif Tiff Webp Psd Pcx Xbm Xpm Dds Ftex Gbr Grib \
Icns Im Imt Iptc McIrdas Mpo Msp Pcd Pixar Ppm Sgi \
Spider Sun Tga Wal Wmf; do
rm -f "$sp_dir/PIL/${plugin}ImagePlugin.py" 2>/dev/null || true
rm -f "$sp_dir/PIL/${plugin}ImagePlugin.pyc" 2>/dev/null || true
done
# ── zeroconf ─────────────────────────────────────────────
# DO NOT strip zeroconf/_services — the compiled Cython _listener.pyd
# imports from it, and the import fails at runtime with:
# ModuleNotFoundError: No module named 'zeroconf._services'
# Same class of bug as numpy — "presumed unused" submodule is actually
# imported internally by the package's own compiled code.
# ── Strip debug symbols ──────────────────────────────────
if command -v strip &>/dev/null; then
echo " Stripping debug symbols from .$ext_suffix files..."
find "$sp_dir" -name "*.$ext_suffix" -exec strip --strip-debug {} \; 2>/dev/null || true
fi
# ── Remove ledgrab if pip-installed ───────────────
rm -rf "$sp_dir"/ledgrab* "$sp_dir"/ledgrab*.dist-info 2>/dev/null || true
local cleaned_size
cleaned_size=$(du -sh "$sp_dir" | cut -f1)
echo " Site-packages after cleanup: $cleaned_size"
}
# ── Pre-compile .py → .pyc (keep sources) ────────────────────
#
# MUST run AFTER cleanup_site_packages. Speeds up first startup by
# producing .pyc alongside .py. We deliberately do NOT delete .py
# sources afterwards:
#
# 1. OpenCV's loader does literal file I/O on cv2/config.py (not
# an import) — stripping it breaks `import cv2` with:
# "OpenCV loader: missing configuration file: ['config.py']".
# 2. Other packages may do similar tricks (inspect.getsource,
# runtime introspection, __file__-relative data loading).
# 3. The size saving (~30%) isn't worth the whack-a-mole of
# shipping broken installers. We already hit this with
# numpy.linalg and zeroconf._services — enough incidents.
#
# Args:
# $1 — directory to compile (site-packages or app/src)
# $2 — python executable to use (default: python3)
compile_and_strip_sources() {
local target_dir="$1"
local py_cmd="${2:-python3}"
if [ ! -d "$target_dir" ]; then
return 0
fi
echo " Pre-compiling Python bytecode in $(basename "$target_dir")..."
"$py_cmd" -m compileall -b -q "$target_dir" 2>/dev/null || {
echo " ERROR: compileall failed for $target_dir — aborting"
return 1
}
# Drop __pycache__ to save the duplicated PEP-3147 copies; the
# `-b` flag above placed legacy .pyc next to each .py, so nothing
# of value is lost here.
find "$target_dir" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
}
# ── Import smoke test ────────────────────────────────────────
#
# Verifies that every top-level dependency that ledgrab actually
# uses can be imported from the stripped site-packages. Catches regressions
# where cleanup_site_packages removes a submodule that turns out to be
# imported internally by the package (e.g. numpy.linalg, zeroconf._services).
# Failing here is cheap; failing on a user's machine after install is not.
#
# Args:
# $1 — path to site-packages to test against
# $2 — python executable
# $3 — (optional) extra PYTHONPATH entry (e.g. app/src for ledgrab)
smoke_test_imports() {
local sp_dir="$1"
local py_cmd="${2:-python3}"
local extra_path="${3:-}"
echo " Running import smoke test..."
local pypath="$sp_dir"
if [ -n "$extra_path" ]; then
pypath="$extra_path:$sp_dir"
fi
# Modules that MUST import cleanly IF PRESENT. We don't enforce
# installation — Pillow for example is only a Windows dep. But if a
# module's top-level package dir exists in site-packages and we
# can't import it, that's a broken install and we abort.
local smoke_script
smoke_script=$(cat <<'PYEOF'
import importlib
import os
import sys
sp_dir = sys.argv[1]
# (module_name, site-packages path to check for presence)
candidates = [
('numpy', 'numpy'),
('numpy.linalg', 'numpy/linalg'),
('numpy.lib', 'numpy/lib'),
('numpy.matrixlib', 'numpy/matrixlib'),
('cv2', 'cv2'),
('fastapi', 'fastapi'),
('uvicorn', 'uvicorn'),
('starlette', 'starlette'),
('pydantic', 'pydantic'),
('zeroconf', 'zeroconf'),
('zeroconf._services', 'zeroconf/_services'),
('PIL', 'PIL'),
('PIL.Image', 'PIL'),
('yaml', 'yaml'),
]
tested = 0
skipped = 0
failed = []
for mod, path in candidates:
if not os.path.exists(os.path.join(sp_dir, path)):
skipped += 1
continue
try:
importlib.import_module(mod)
tested += 1
except Exception as e:
failed.append(f'{mod}: {type(e).__name__}: {e}')
if failed:
print('SMOKE TEST FAILED:', file=sys.stderr)
for f in failed:
print(f' {f}', file=sys.stderr)
sys.exit(1)
print(f' Smoke test passed ({tested} imported, {skipped} not installed)')
PYEOF
)
if ! PYTHONPATH="$pypath" "$py_cmd" -c "$smoke_script" "$sp_dir"; then
echo " ERROR: smoke test failed — site-packages is broken, aborting build"
return 1
fi
}
@@ -14,35 +14,22 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
BUILD_DIR="$SCRIPT_DIR"
DIST_NAME="LedGrab"
DIST_DIR="$BUILD_DIR/$DIST_NAME"
SERVER_DIR="$SCRIPT_DIR/server"
SERVER_DIR="$REPO_ROOT/server"
PYTHON_DIR="$DIST_DIR/python"
APP_DIR="$DIST_DIR/app"
PYTHON_VERSION="${PYTHON_VERSION:-3.11.9}"
source "$SCRIPT_DIR/build-common.sh"
# ── Version detection ────────────────────────────────────────
VERSION="${1:-}"
if [ -z "$VERSION" ]; then
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$VERSION" ]; then
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
fi
if [ -z "$VERSION" ]; then
VERSION=$(grep -oP '^version\s*=\s*"\K[^"]+' "$SERVER_DIR/pyproject.toml" 2>/dev/null || echo "0.0.0")
fi
VERSION_CLEAN="${VERSION#v}"
detect_version "${1:-}"
ZIP_NAME="LedGrab-v${VERSION_CLEAN}-win-x64.zip"
# Stamp the resolved version into pyproject.toml so that
# importlib.metadata reads the correct value at runtime.
sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" "$SERVER_DIR/pyproject.toml"
echo "=== Cross-building LedGrab v${VERSION_CLEAN} (Windows from Linux) ==="
echo " Embedded Python: $PYTHON_VERSION"
echo " Output: build/$ZIP_NAME"
@@ -50,11 +37,8 @@ echo ""
# ── Clean ────────────────────────────────────────────────────
if [ -d "$DIST_DIR" ]; then
echo "[1/9] Cleaning previous build..."
rm -rf "$DIST_DIR"
fi
mkdir -p "$DIST_DIR"
echo "[1/9] Cleaning..."
clean_dist
# ── Download Windows embedded Python ─────────────────────────
@@ -83,7 +67,7 @@ if ! grep -q 'Lib\\site-packages' "$PTH_FILE"; then
echo 'Lib\site-packages' >> "$PTH_FILE"
fi
# Embedded Python ._pth overrides PYTHONPATH, so we must add the app
# source directory here for wled_controller to be importable
# source directory here for ledgrab to be importable
if ! grep -q '\.\./app/src' "$PTH_FILE"; then
echo '../app/src' >> "$PTH_FILE"
fi
@@ -194,16 +178,18 @@ echo "[6/9] Downloading Windows dependencies..."
WHEEL_DIR="$BUILD_DIR/win-wheels"
mkdir -p "$WHEEL_DIR"
# Core dependencies (cross-platform, should have win_amd64 wheels)
# We parse pyproject.toml deps and download win_amd64 wheels.
# For packages that are pure Python, --only-binary will fail,
# so we fall back to allowing source for those.
# Core dependencies (cross-platform, should have win_amd64 wheels).
# KEEP IN SYNC with server/pyproject.toml [project.dependencies] — this
# list duplicates it because cross-build on Linux can't invoke `pip install
# <path>` against pyproject.toml with a Windows target. Missing entries
# ship a broken installer that silently fails under pythonw.exe (no
# traceback visible to the user). Audit after every pyproject.toml edit.
DEPS=(
"fastapi>=0.115.0"
"uvicorn[standard]>=0.32.0"
"cryptography>=42.0.0"
"httpx>=0.27.2"
"mss>=9.0.2"
"Pillow>=10.4.0"
"numpy>=2.1.3"
"pydantic>=2.9.2"
"pydantic-settings>=2.6.0"
@@ -220,8 +206,8 @@ DEPS=(
"sounddevice>=0.5"
"aiomqtt>=2.0.0"
"openrgb-python>=0.2.15"
# camera extra
"opencv-python-headless>=4.8.0"
"just-playback>=0.1.7"
)
# Windows-only deps
@@ -232,8 +218,13 @@ WIN_DEPS=(
"winrt-Windows.Foundation>=3.0.0"
"winrt-Windows.Foundation.Collections>=3.0.0"
"winrt-Windows.ApplicationModel>=3.0.0"
# System tray
# System tray (Pillow needed by pystray for tray icon)
"pystray>=0.19.0"
"Pillow>=10.4.0"
# Windows screen capture engines (mss is the only fallback without these)
"dxcam>=0.0.5"
"bettercam>=1.0.0"
"windows-capture>=1.5.0"
)
# Download cross-platform deps (prefer binary, allow source for pure Python)
@@ -286,78 +277,52 @@ for sdist in "$WHEEL_DIR"/*.tar.gz; do
done
# ── Reduce package size ────────────────────────────────────────
echo " Cleaning up to reduce size..."
# Remove caches, tests, docs, type stubs
find "$SITE_PACKAGES" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -type d -name test -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -name "*.pyi" -delete 2>/dev/null || true
cleanup_site_packages "$SITE_PACKAGES" "pyd" "dll"
# Remove pip and setuptools (not needed at runtime)
rm -rf "$SITE_PACKAGES"/pip "$SITE_PACKAGES"/pip-* 2>/dev/null || true
rm -rf "$SITE_PACKAGES"/setuptools "$SITE_PACKAGES"/setuptools-* "$SITE_PACKAGES"/pkg_resources 2>/dev/null || true
rm -rf "$SITE_PACKAGES"/_distutils_hack 2>/dev/null || true
# Remove pythonwin GUI IDE and help file (ships with pywin32 but not needed)
# Windows-specific cleanup
rm -rf "$SITE_PACKAGES"/pythonwin 2>/dev/null || true
rm -f "$SITE_PACKAGES"/PyWin32.chm 2>/dev/null || true
# OpenCV: remove ffmpeg DLL (28MB, only for video file I/O, not camera),
# Haar cascades (2.6MB), and misc dev files
CV2_DIR="$SITE_PACKAGES/cv2"
if [ -d "$CV2_DIR" ]; then
rm -f "$CV2_DIR"/opencv_videoio_ffmpeg*.dll 2>/dev/null || true
rm -rf "$CV2_DIR/data" "$CV2_DIR/gapi" "$CV2_DIR/misc" "$CV2_DIR/utils" 2>/dev/null || true
rm -rf "$CV2_DIR/typing_stubs" "$CV2_DIR/typing" 2>/dev/null || true
fi
# numpy: remove tests, f2py, typing stubs
rm -rf "$SITE_PACKAGES/numpy/tests" "$SITE_PACKAGES/numpy/*/tests" 2>/dev/null || true
rm -rf "$SITE_PACKAGES/numpy/f2py" 2>/dev/null || true
rm -rf "$SITE_PACKAGES/numpy/typing" 2>/dev/null || true
rm -rf "$SITE_PACKAGES/numpy/_pyinstaller" 2>/dev/null || true
# Pillow: remove unused image plugins' test data
rm -rf "$SITE_PACKAGES/PIL/tests" 2>/dev/null || true
# winrt: remove type stubs
find "$SITE_PACKAGES/winrt" -name "*.pyi" -delete 2>/dev/null || true
# Remove wled_controller if it got installed
rm -rf "$SITE_PACKAGES"/wled_controller* "$SITE_PACKAGES"/wled*.dist-info 2>/dev/null || true
# Pre-compile and strip .py sources. MUST run AFTER cleanup (so we don't
# waste work compiling files about to be deleted). Uses host python —
# PYTHON_VERSION above must match the embedded Python major.minor or
# the generated .pyc will ImportError on the target.
compile_and_strip_sources "$SITE_PACKAGES" "python"
CLEANED_SIZE=$(du -sh "$SITE_PACKAGES" | cut -f1)
echo " Site-packages after cleanup: $CLEANED_SIZE"
# Windows cross-build: host python can't load win_amd64 .pyd files, so
# we can't `import numpy` for real. Instead, check that the submodules
# known to be imported internally exist on disk — the same landmines that
# cost users a broken v0.0.0.dev0 installer.
echo " Verifying required submodules exist after cleanup..."
for required in \
"numpy/linalg" "numpy/lib" "numpy/matrixlib" "numpy/ma" \
"zeroconf/_services" \
"cryptography" "cffi" "just_playback"; do
if [ ! -d "$SITE_PACKAGES/$required" ] && [ ! -f "$SITE_PACKAGES/$required.py" ] && [ ! -f "$SITE_PACKAGES/$required.pyc" ]; then
echo " ERROR: $required missing from site-packages — either cleanup_site_packages removed something required, or DEPS is out of sync with pyproject.toml. Aborting."
exit 1
fi
done
echo " All required submodules present."
WHEEL_COUNT=$(ls "$WHEEL_DIR"/*.whl 2>/dev/null | wc -l)
echo " Installed $WHEEL_COUNT packages"
# ── Build frontend ───────────────────────────────────────────
echo "[7/9] Building frontend bundle..."
(cd "$SERVER_DIR" && npm ci --loglevel error && npm run build) 2>&1 | {
grep -v 'RemoteException' || true
}
echo "[7/9] Building frontend..."
build_frontend
# ── Copy application files ───────────────────────────────────
echo "[8/9] Copying application files..."
mkdir -p "$APP_DIR"
copy_app_files
cp -r "$SERVER_DIR/src" "$APP_DIR/src"
cp -r "$SERVER_DIR/config" "$APP_DIR/config"
mkdir -p "$DIST_DIR/data" "$DIST_DIR/logs"
# Clean up source maps and __pycache__
find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true
find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
# Pre-compile Python bytecode for faster startup
echo " Pre-compiling Python bytecode..."
# Pre-compile app source for faster startup (keep .py too — app source
# is small and easier to debug in-place if a user reports an issue)
python -m compileall -b -q "$APP_DIR/src" 2>/dev/null || true
python -m compileall -b -q "$SITE_PACKAGES" 2>/dev/null || true
# ── Create launcher ──────────────────────────────────────────
@@ -369,14 +334,20 @@ cd /d "%~dp0"
:: Set paths
set PYTHONPATH=%~dp0app\src
set WLED_CONFIG_PATH=%~dp0app\config\default_config.yaml
set LEDGRAB_CONFIG_PATH=%~dp0app\config\default_config.yaml
:: Tcl/Tk ship under python\ but Tk's default search path is
:: <python.exe>\..\lib\tcl8.6. Point it at the right location so
:: the screen-overlay feature (tkinter) can start without errors.
set TCL_LIBRARY=%~dp0python\tcl8.6
set TK_LIBRARY=%~dp0python\tk8.6
:: Create data directory if missing
if not exist "%~dp0data" mkdir "%~dp0data"
if not exist "%~dp0logs" mkdir "%~dp0logs"
:: Start the server (tray icon handles UI and exit)
"%~dp0python\pythonw.exe" -m wled_controller
"%~dp0python\pythonw.exe" -m ledgrab
LAUNCHER
# Convert launcher to Windows line endings
@@ -384,7 +355,7 @@ sed -i 's/$/\r/' "$DIST_DIR/LedGrab.bat"
# Copy hidden launcher VBS
mkdir -p "$DIST_DIR/scripts"
cp server/scripts/start-hidden.vbs "$DIST_DIR/scripts/"
cp "$SERVER_DIR/scripts/start-hidden.vbs" "$DIST_DIR/scripts/"
# ── Create autostart scripts ─────────────────────────────────
+28 -11
View File
@@ -34,11 +34,11 @@ param(
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue' # faster downloads
$ScriptRoot = $PSScriptRoot
$BuildDir = Join-Path $ScriptRoot "build"
$RepoRoot = Split-Path $PSScriptRoot -Parent
$BuildDir = $PSScriptRoot
$DistName = "LedGrab"
$DistDir = Join-Path $BuildDir $DistName
$ServerDir = Join-Path $ScriptRoot "server"
$ServerDir = Join-Path $RepoRoot "server"
$PythonDir = Join-Path $DistDir "python"
$AppDir = Join-Path $DistDir "app"
@@ -58,7 +58,7 @@ if (-not $Version) {
}
if (-not $Version) {
# Parse from __init__.py
$initFile = Join-Path $ServerDir "src\wled_controller\__init__.py"
$initFile = Join-Path $ServerDir "src\ledgrab\__init__.py"
$match = Select-String -Path $initFile -Pattern '__version__\s*=\s*"([^"]+)"'
if ($match) { $Version = $match.Matches[0].Groups[1].Value }
}
@@ -107,7 +107,7 @@ if ($pthContent -notmatch 'Lib\\site-packages') {
$pthContent = $pthContent.TrimEnd() + "`nLib\site-packages`n"
}
# Embedded Python ._pth overrides PYTHONPATH, so add the app source path
# directly for wled_controller to be importable
# directly for ledgrab to be importable
if ($pthContent -notmatch '\.\.[/\\]app[/\\]src') {
$pthContent = $pthContent.TrimEnd() + "`n..\app\src`n"
}
@@ -130,7 +130,7 @@ if ($LASTEXITCODE -ne 0) { throw "Failed to install pip" }
# ── Install dependencies ──────────────────────────────────────
Write-Host "[5/8] Installing dependencies..."
$extras = "camera,notifications,tray"
$extras = "camera,notifications"
if (-not $SkipPerf) { $extras += ",perf" }
# Install the project (pulls all deps via pyproject.toml), then remove
@@ -144,10 +144,10 @@ if ($LASTEXITCODE -ne 0) {
Write-Host " Some optional deps may have failed (continuing)..." -ForegroundColor Yellow
}
# Remove the installed wled_controller package to avoid duplication
# Remove the installed ledgrab package to avoid duplication
$sitePackages = Join-Path $PythonDir "Lib\site-packages"
Get-ChildItem -Path $sitePackages -Filter "wled*" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $sitePackages -Filter "wled*.dist-info" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $sitePackages -Filter "ledgrab*" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $sitePackages -Filter "ledgrab*.dist-info" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# Clean up caches and test files to reduce size
Write-Host " Cleaning up caches..."
@@ -196,6 +196,17 @@ New-Item -ItemType Directory -Path (Join-Path $DistDir "logs") -Force | Out-Null
Get-ChildItem -Path $srcDest -Recurse -Filter "*.map" | Remove-Item -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $srcDest -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# Patch the fallback version in the bundled __init__.py so the WebUI always
# reports the release version — the installer strips ledgrab-*.dist-info from
# site-packages (above), so importlib.metadata falls back to this literal.
$bundledInit = Join-Path $srcDest "ledgrab\__init__.py"
if (Test-Path $bundledInit) {
$initContent = Get-Content $bundledInit -Raw
$patched = [regex]::Replace($initContent, '_FALLBACK_VERSION\s*=\s*"[^"]*"', "_FALLBACK_VERSION = `"$VersionClean`"")
Set-Content -Path $bundledInit -Value $patched -NoNewline
Write-Host " Patched _FALLBACK_VERSION -> $VersionClean"
}
# ── Create launcher ────────────────────────────────────────────
Write-Host "[8/8] Creating launcher..."
@@ -206,14 +217,20 @@ cd /d "%~dp0"
:: Set paths
set PYTHONPATH=%~dp0app\src
set WLED_CONFIG_PATH=%~dp0app\config\default_config.yaml
set LEDGRAB_CONFIG_PATH=%~dp0app\config\default_config.yaml
:: Tcl/Tk ship under python\ but Tk's default search path is
:: <python.exe>\..\lib\tcl8.6. Point it at the right location so
:: the screen-overlay feature (tkinter) can start without errors.
set TCL_LIBRARY=%~dp0python\tcl8.6
set TK_LIBRARY=%~dp0python\tk8.6
:: Create data directory if missing
if not exist "%~dp0data" mkdir "%~dp0data"
if not exist "%~dp0logs" mkdir "%~dp0logs"
:: Start the server (tray icon handles UI and exit)
"%~dp0python\pythonw.exe" -m wled_controller
"%~dp0python\pythonw.exe" -m ledgrab
'@
$launcherContent = $launcherContent -replace '%VERSION%', $VersionClean
+27 -46
View File
@@ -10,45 +10,29 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
BUILD_DIR="$SCRIPT_DIR"
DIST_NAME="LedGrab"
DIST_DIR="$BUILD_DIR/$DIST_NAME"
SERVER_DIR="$SCRIPT_DIR/server"
SERVER_DIR="$REPO_ROOT/server"
VENV_DIR="$DIST_DIR/venv"
APP_DIR="$DIST_DIR/app"
source "$SCRIPT_DIR/build-common.sh"
# ── Version detection ────────────────────────────────────────
VERSION="${1:-}"
if [ -z "$VERSION" ]; then
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$VERSION" ]; then
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
fi
if [ -z "$VERSION" ]; then
VERSION=$(grep -oP '^version\s*=\s*"\K[^"]+' "$SERVER_DIR/pyproject.toml" 2>/dev/null || echo "0.0.0")
fi
VERSION_CLEAN="${VERSION#v}"
detect_version "${1:-}"
TAR_NAME="LedGrab-v${VERSION_CLEAN}-linux-x64.tar.gz"
# Stamp the resolved version into pyproject.toml so that
# importlib.metadata reads the correct value at runtime.
sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" "$SERVER_DIR/pyproject.toml"
echo "=== Building LedGrab v${VERSION_CLEAN} (Linux) ==="
echo " Output: build/$TAR_NAME"
echo ""
# ── Clean ────────────────────────────────────────────────────
if [ -d "$DIST_DIR" ]; then
echo "[1/7] Cleaning previous build..."
rm -rf "$DIST_DIR"
fi
mkdir -p "$DIST_DIR"
echo "[1/7] Cleaning..."
clean_dist
# ── Create virtualenv ────────────────────────────────────────
@@ -60,38 +44,35 @@ pip install --upgrade pip --quiet
# ── Install dependencies ─────────────────────────────────────
echo "[3/7] Installing dependencies..."
pip install --quiet "${SERVER_DIR}[camera,notifications]" 2>&1 | {
pip install --quiet "${SERVER_DIR}[notifications]" 2>&1 | {
grep -i 'error\|failed' || true
}
# Remove the installed wled_controller package (PYTHONPATH handles app code)
SITE_PACKAGES="$VENV_DIR/lib/python*/site-packages"
rm -rf $SITE_PACKAGES/wled_controller* $SITE_PACKAGES/wled*.dist-info 2>/dev/null || true
# Resolve site-packages path (glob expand)
SITE_PACKAGES=$(echo "$VENV_DIR"/lib/python*/site-packages)
# Clean up caches
find "$VENV_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find "$VENV_DIR" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
find "$VENV_DIR" -type d -name test -exec rm -rf {} + 2>/dev/null || true
# Clean up with shared function
cleanup_site_packages "$SITE_PACKAGES" "so" "so"
# Pre-compile and strip .py sources (must happen AFTER cleanup)
compile_and_strip_sources "$SITE_PACKAGES" "python"
# Fail loud if cleanup broke any required import
smoke_test_imports "$SITE_PACKAGES" "python"
# ── Build frontend ───────────────────────────────────────────
echo "[4/7] Building frontend bundle..."
(cd "$SERVER_DIR" && npm ci --loglevel error && npm run build) 2>&1 | {
grep -v 'RemoteException' || true
}
echo "[4/7] Building frontend..."
build_frontend
# ── Copy application files ───────────────────────────────────
echo "[5/7] Copying application files..."
mkdir -p "$APP_DIR"
copy_app_files
cp -r "$SERVER_DIR/src" "$APP_DIR/src"
cp -r "$SERVER_DIR/config" "$APP_DIR/config"
mkdir -p "$DIST_DIR/data" "$DIST_DIR/logs"
# Clean up source maps and __pycache__
find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true
find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
# Pre-compile app source for faster startup (keep .py too — app source
# is small and easier to debug in-place if a user reports an issue)
python -m compileall -b -q "$APP_DIR/src" 2>/dev/null || true
# ── Create launcher ──────────────────────────────────────────
@@ -103,12 +84,12 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
export PYTHONPATH="$SCRIPT_DIR/app/src"
export WLED_CONFIG_PATH="$SCRIPT_DIR/app/config/default_config.yaml"
export LEDGRAB_CONFIG_PATH="$SCRIPT_DIR/app/config/default_config.yaml"
mkdir -p "$SCRIPT_DIR/data" "$SCRIPT_DIR/logs"
source "$SCRIPT_DIR/venv/bin/activate"
exec python -m wled_controller.main
exec python -m ledgrab.main
LAUNCHER
sed -i "s/VERSION_PLACEHOLDER/${VERSION_CLEAN}/" "$DIST_DIR/run.sh"
+37 -10
View File
@@ -22,7 +22,7 @@
!endif
Name "${APPNAME} v${VERSION}"
OutFile "build\${APPNAME}-v${VERSION}-win-x64-setup.exe"
OutFile "${APPNAME}-v${VERSION}-win-x64-setup.exe"
InstallDir "$LOCALAPPDATA\${APPNAME}"
InstallDirRegKey HKCU "Software\${APPNAME}" "InstallDir"
RequestExecutionLevel user
@@ -30,6 +30,8 @@ SetCompressor /SOLID lzma
; Modern UI Configuration
!define MUI_ICON "..\server\src\ledgrab\static\icons\icon.ico"
!define MUI_UNICON "..\server\src\ledgrab\static\icons\icon.ico"
!define MUI_ABORTWARNING
; Pages
@@ -85,11 +87,29 @@ Section "!${APPNAME} (required)" SecCore
SetOutPath "$INSTDIR"
; Wipe prior payload dirs before extracting. NSIS File /r MERGES files
; on top of existing ones on an upgrade, stale .pyc/.pyd from the old
; version (and any files removed or renamed since) would survive,
; producing a half-old/half-new install that presents as "version
; mismatch" or "duplicate package" ImportErrors at runtime.
; IMPORTANT: only touch payload dirs never $INSTDIR\data or
; $INSTDIR\logs (user config must be preserved across upgrades).
RMDir /r "$INSTDIR\python"
RMDir /r "$INSTDIR\app"
RMDir /r "$INSTDIR\scripts"
Delete "$INSTDIR\LedGrab.bat"
; Legacy leftovers from the wled_controller-era install. The current
; build does not ship debug.bat, but upgrades from older versions left
; one behind with a stale `-m wled_controller` command that gives a
; misleading ModuleNotFoundError when run. Remove it on upgrade.
Delete "$INSTDIR\debug.bat"
Delete "$INSTDIR\debug.log"
; Copy the entire portable build
File /r "build\LedGrab\python"
File /r "build\LedGrab\app"
File /r "build\LedGrab\scripts"
File "build\LedGrab\LedGrab.bat"
File /r "LedGrab\python"
File /r "LedGrab\app"
File /r "LedGrab\scripts"
File "LedGrab\LedGrab.bat"
; Create data and logs directories
CreateDirectory "$INSTDIR\data"
@@ -102,7 +122,7 @@ Section "!${APPNAME} (required)" SecCore
CreateDirectory "$SMPROGRAMS\${APPNAME}"
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\python\pythonw.exe" 0
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" "$INSTDIR\uninstall.exe"
; Registry: install location + Add/Remove Programs entry
@@ -117,10 +137,12 @@ Section "!${APPNAME} (required)" SecCore
"UninstallString" '"$INSTDIR\uninstall.exe"'
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"InstallLocation" "$INSTDIR"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"DisplayIcon" "$INSTDIR\app\src\ledgrab\static\icons\icon.ico"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"Publisher" "Alexei Dolgolyov"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"URLInfoAbout" "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
"URLInfoAbout" "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"NoModify" 1
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
@@ -136,13 +158,16 @@ SectionEnd
Section "Desktop shortcut" SecDesktop
CreateShortcut "$DESKTOP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\python\pythonw.exe" 0
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
SectionEnd
Section "Start with Windows" SecAutostart
; Pass --autostart so the VBS sets LEDGRAB_AUTOSTART=1 and the app suppresses
; the browser auto-open on Windows login. Manual launches (desktop / start
; menu) don't pass the arg, so they keep opening the WebUI tab.
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\python\pythonw.exe" 0
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}" --autostart' \
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
SectionEnd
; Section Descriptions
@@ -171,6 +196,8 @@ Section "Uninstall"
RMDir /r "$INSTDIR\app"
RMDir /r "$INSTDIR\scripts"
Delete "$INSTDIR\LedGrab.bat"
Delete "$INSTDIR\debug.bat"
Delete "$INSTDIR\debug.log"
Delete "$INSTDIR\uninstall.exe"
; Remove logs (but keep data/)
-143
View File
@@ -1,143 +0,0 @@
# Auto-Update Plan — Phase 1: Check & Notify
> Created: 2026-03-25. Status: **planned, not started.**
## Backend Architecture
### Release Provider Abstraction
```
core/update/
release_provider.py — ABC: get_releases(), get_releases_page_url()
gitea_provider.py — Gitea REST API implementation
version_check.py — normalize_version(), is_newer() using packaging.version
update_service.py — Background asyncio task + state machine
```
**`ReleaseProvider` interface** — two methods:
- `get_releases(limit) → list[ReleaseInfo]` — fetch releases (newest first)
- `get_releases_page_url() → str` — link for "view on web"
**`GiteaReleaseProvider`** calls `GET {base_url}/api/v1/repos/{repo}/releases`. Swapping to GitHub later means implementing the same interface against `api.github.com`.
**Data models:**
```python
@dataclass(frozen=True)
class AssetInfo:
name: str # "LedGrab-v0.3.0-win-x64.zip"
size: int # bytes
download_url: str
@dataclass(frozen=True)
class ReleaseInfo:
tag: str # "v0.3.0"
version: str # "0.3.0"
name: str # "LedGrab v0.3.0"
body: str # release notes markdown
prerelease: bool
published_at: str # ISO 8601
assets: tuple[AssetInfo, ...]
```
### Version Comparison
`version_check.py` — normalize Gitea tags to PEP 440:
- `v0.3.0-alpha.1``0.3.0a1`
- `v0.3.0-beta.2``0.3.0b2`
- `v0.3.0-rc.3``0.3.0rc3`
Uses `packaging.version.Version` for comparison.
### Update Service
Follows the **AutoBackupEngine pattern**:
- Settings in `Database.get_setting("auto_update")`
- asyncio.Task for periodic checks
- 30s startup delay (avoid slowing boot)
- 60s debounce on manual checks
**State machine (Phase 1):** `IDLE → CHECKING → UPDATE_AVAILABLE`
No download/apply in Phase 1 — just detection and notification.
**Settings:** `enabled` (bool), `check_interval_hours` (float), `channel` ("stable" | "pre-release")
**Persisted state:** `dismissed_version`, `last_check` (survives restarts)
### API Endpoints
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/v1/system/update/status` | Current state + available version |
| `POST` | `/api/v1/system/update/check` | Trigger immediate check |
| `POST` | `/api/v1/system/update/dismiss` | Dismiss notification for current version |
| `GET` | `/api/v1/system/update/settings` | Get settings |
| `PUT` | `/api/v1/system/update/settings` | Update settings |
### Wiring
- New `get_update_service()` in `dependencies.py`
- `UpdateService` created in `main.py` lifespan, `start()`/`stop()` alongside other engines
- Router registered in `api/__init__.py`
- WebSocket event: `update_available` fired via `processor_manager.fire_event()`
## Frontend
### Version badge highlight
The existing `#server-version` pill in the header gets a CSS class `has-update` when a newer version exists — changes the background to `var(--warning-color)` with a subtle pulse, making it clickable to open the update panel in settings.
### Notification popup
On `server:update_available` WebSocket event (and on page load if status says `has_update` and not dismissed):
- A **persistent dismissible banner** slides in below the header (not the ephemeral 3s toast)
- Shows: "Version {x.y.z} is available" + [View Release Notes] + [Dismiss]
- Dismiss calls `POST /dismiss` and hides the bar for that version
- Stored in `localStorage` so it doesn't re-show after page refresh for dismissed versions
### Settings tab: "Updates"
New 5th tab in the settings modal:
- Current version display
- "Check for updates" button + spinner
- Channel selector (stable / pre-release) via IconSelect
- Auto-check toggle + interval selector
- When update available: release name, notes preview, link to releases page
### i18n keys
New `update.*` keys in `en.json`, `ru.json`, `zh.json` for all labels.
## Files to Create
| File | Purpose |
|------|---------|
| `core/update/__init__.py` | Package init |
| `core/update/release_provider.py` | Abstract provider interface + data models |
| `core/update/gitea_provider.py` | Gitea API implementation |
| `core/update/version_check.py` | Semver normalization + comparison |
| `core/update/update_service.py` | Background service + state machine |
| `api/routes/update.py` | REST endpoints |
| `api/schemas/update.py` | Pydantic request/response models |
## Files to Modify
| File | Change |
|------|--------|
| `api/__init__.py` | Register update router |
| `api/dependencies.py` | Add `get_update_service()` |
| `main.py` | Create & start/stop UpdateService in lifespan |
| `templates/modals/settings.html` | Add Updates tab |
| `static/js/features/settings.ts` | Update check/settings UI logic |
| `static/js/core/api.ts` | Version badge highlight on health check |
| `static/css/layout.css` | `.has-update` styles for version badge |
| `static/locales/en.json` | i18n keys |
| `static/locales/ru.json` | i18n keys |
| `static/locales/zh.json` | i18n keys |
## Future Phases (not in scope)
- **Phase 2**: Download & stage artifacts
- **Phase 3**: Apply update & restart (external updater script, NSIS silent mode)
- **Phase 4**: Checksums, "What's new" dialog, update history
+10 -9
View File
@@ -7,7 +7,8 @@
| File | Trigger | Purpose |
|------|---------|---------|
| `.gitea/workflows/test.yml` | Push/PR to master | Lint (ruff) + pytest |
| `.gitea/workflows/release.yml` | Tag `v*` | Build artifacts + create Gitea release |
| `.gitea/workflows/release.yml` | Tag `v*` | Build Windows/Linux/Docker artifacts + create Gitea release |
| `.gitea/workflows/build-android.yml` | Push master (android/server paths), tag `v*`, manual | Build Android APK; attach to release on tag |
## Release Pipeline (`release.yml`)
@@ -17,7 +18,7 @@ Four parallel jobs triggered by pushing a `v*` tag:
Creates the Gitea release with a description table listing all artifacts. **The description must stay in sync with actual build outputs** — if you add/remove/rename an artifact, update the body template here.
### 2. `build-windows` (cross-built from Linux)
- Runs `build-dist-windows.sh` on Ubuntu with NSIS + msitools
- Runs `build/build-dist-windows.sh` on Ubuntu with NSIS + msitools
- Downloads Windows embedded Python 3.11 + pip wheels cross-platform
- Bundles tkinter from Python MSI via msiextract
- Builds frontend (`npm run build`)
@@ -25,7 +26,7 @@ Creates the Gitea release with a description table listing all artifacts. **The
- Produces: **`LedGrab-{tag}-win-x64.zip`** (portable) and **`LedGrab-{tag}-setup.exe`** (NSIS installer)
### 3. `build-linux`
- Runs `build-dist.sh` on Ubuntu
- Runs `build/build-dist.sh` on Ubuntu
- Creates a venv, installs deps, builds frontend
- Produces: **`LedGrab-{tag}-linux-x64.tar.gz`**
@@ -38,8 +39,8 @@ Creates the Gitea release with a description table listing all artifacts. **The
| Script | Platform | Output |
|--------|----------|--------|
| `build-dist-windows.sh` | Linux → Windows cross-build | ZIP + NSIS installer |
| `build-dist.sh` | Linux native | tarball |
| `build/build-dist-windows.sh` | Linux → Windows cross-build | ZIP + NSIS installer |
| `build/build-dist.sh` | Linux native | tarball |
| `server/Dockerfile` | Docker | Container image |
## Release Versioning
@@ -71,7 +72,7 @@ Build scripts use a fallback chain: CLI argument → exact git tag → CI env va
- **Launch after install**: Use `MUI_FINISHPAGE_RUN_FUNCTION` (not `MUI_FINISHPAGE_RUN_PARAMETERS` — NSIS `Exec` chokes on quoting). Still requires `MUI_FINISHPAGE_RUN ""` defined for checkbox visibility
- **Detect running instance**: `.onInit` checks file lock on `python.exe`, offers to kill process before install
- **Uninstall preserves user data**: Remove `python/`, `app/`, `logs/` but NOT `data/`
- **CI build**: `sudo apt-get install -y nsis msitools zip` then `makensis -DVERSION="${VERSION}" installer.nsi`
- **CI build**: `sudo apt-get install -y nsis msitools zip` then `makensis -DVERSION="${VERSION}" build/installer.nsi`
## Hidden Launcher (VBS)
@@ -135,12 +136,12 @@ The `create-release` job has fallback logic — if the release already exists fo
```bash
npm ci && npm run build # frontend
bash build-dist-windows.sh v1.0.0 # Windows dist
"/c/Program Files (x86)/NSIS/makensis.exe" -DVERSION="1.0.0" installer.nsi # installer
bash build/build-dist-windows.sh v1.0.0 # Windows dist
"/c/Program Files (x86)/NSIS/makensis.exe" -DVERSION="1.0.0" build/installer.nsi # installer
```
### Iterating on installer only
If only `installer.nsi` changed (not app code), skip the full rebuild — just re-run `makensis`. If app code changed, re-run `build-dist-windows.sh` first since `dist/` is a snapshot.
If only `installer.nsi` changed (not app code), skip the full rebuild — just re-run `makensis`. If app code changed, re-run `build/build-dist-windows.sh` first since `dist/` is a snapshot.
### Common issues
+33 -5
View File
@@ -4,7 +4,7 @@
## CSS Custom Properties (Variables)
Defined in `server/src/wled_controller/static/css/base.css`.
Defined in `server/src/ledgrab/static/css/base.css`.
**IMPORTANT:** There is NO `--accent` variable. Always use `--primary-color` for accent/brand color.
@@ -90,10 +90,26 @@ Plain `<select>` dropdowns should be enhanced with visual selectors depending on
Both widgets hide the native `<select>` but keep it in the DOM with its value in sync. **The `<select>` and the visual widget are two separate things — changing one does NOT automatically update the other.** After programmatically changing the `<select>` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes.
**CRITICAL pitfall — `<option>` elements required:** `IconSelect` does NOT create `<option>` elements in the native `<select>` — it only builds a visual popup grid. The native `<select>` must already contain matching `<option value="...">` elements (either from the Jinja2 template or added via JS) **before** `.value` is set. Setting `.value` on a `<select>` with no matching `<option>` **silently fails** — the value stays empty, and all downstream logic (section switching, auto-naming, type setup) breaks with no error. **When adding a new type to any IconSelect-enhanced `<select>`, you MUST add the `<option>` in the HTML template too.**
**Common pitfall:** Using a preset/palette selector (e.g. gradient preset dropdown or effect type picker) that changes the underlying `<select>` value but forgets to call `.setValue()` on the IconSelect — the visual grid still shows the old selection.
**IMPORTANT:** For `IconSelect` item icons, use SVG icons from `js/core/icon-paths.ts` (via `_icon(P.iconName)`) or styled `<span>` elements (e.g., `<span style="font-weight:bold">A</span>`). **Never use emoji** — they render inconsistently across platforms and themes.
### Dynamic entity lists (e.g. group child devices)
When a modal needs an **ordered list of entity references** (like child devices in a group), use the `EntityPalette.pick()` pattern instead of plain `<select>` dropdowns per row. Each row should be a styled card with:
- An icon (from `getDeviceTypeIcon()` / `getXxxIcon()`) showing the entity type
- The entity name and metadata (LED count, type, etc.)
- SVG icon buttons for reorder (chevron-up/down) and remove (trash)
- A click handler that opens `EntityPalette.pick()` to change the selection
- `data-*` attributes to store the selected value (not hidden `<select>` elements)
See `_addGroupChildRow()` in `device-discovery.ts` for the reference implementation. This pattern keeps the list visually consistent with the rest of the UI and avoids plain HTML selects.
For **mode/type toggles** with 2-4 fixed options (e.g. group mode: Sequence vs Independent), use `IconSelect` with descriptive icons and labels rather than radio buttons or plain selects. See `ensureGroupModeIconSelect()` for the pattern.
### Modal dirty check (discard unsaved changes)
Every editor modal **must** have a dirty check so closing with unsaved changes shows a "Discard unsaved changes?" confirmation. Use the `Modal` base class pattern from `js/core/modal.ts`:
@@ -175,7 +191,7 @@ When adding **new tabs, sections, or major UI elements**, update the correspondi
When you need a new icon:
1. Find the Lucide icon at https://lucide.dev
2. Copy the inner SVG elements (paths, circles, rects) into `icon-paths.js` as a new export
2. Copy the inner SVG elements (paths, circles, rects) into `icon-paths.ts` as a new export
3. Add a corresponding `ICON_*` constant in `icons.ts` using `_svg(P.myIcon)`
4. Import and use the constant in your feature module
@@ -213,7 +229,19 @@ Static HTML using `data-i18n` attributes is handled automatically by the i18n sy
- `fetchWithAuth('/devices/dev_123', { method: 'DELETE' })``DELETE /api/v1/devices/dev_123`
- Passing `/api/v1/gradients` results in **double prefix**: `/api/v1/api/v1/gradients` (404)
For raw `fetch()` without auth (rare), use the full path manually.
**NEVER use raw `fetch()` or `new Audio(url)` / `new Image()` for authenticated endpoints.** These bypass the auth token and will fail with 401. Always use `fetchWithAuth()` and convert to blob URLs when needed (e.g. for `<audio>`, `<img>`, or download links):
```typescript
// WRONG: no auth header — 401
const audio = new Audio(`${API_BASE}/assets/${id}/file`);
// CORRECT: fetch with auth, then create blob URL
const res = await fetchWithAuth(`/assets/${id}/file`);
const blob = await res.blob();
const audio = new Audio(URL.createObjectURL(blob));
```
The only exception is raw `fetch()` for multipart uploads where you must set `Content-Type` to let the browser handle the boundary — but still use `getHeaders()` for the auth token.
## Bundling & Development Workflow
@@ -266,11 +294,11 @@ See [`contexts/chrome-tools.md`](chrome-tools.md) for Chrome MCP tool usage, bro
### Uptime / duration values
Use `formatUptime(seconds)` from `core/ui.js`. Outputs `{s}s`, `{m}m {s}s`, or `{h}h {m}m` via i18n keys `time.seconds`, `time.minutes_seconds`, `time.hours_minutes`.
Use `formatUptime(seconds)` from `core/ui.ts`. Outputs `{s}s`, `{m}m {s}s`, or `{h}h {m}m` via i18n keys `time.seconds`, `time.minutes_seconds`, `time.hours_minutes`.
### Large numbers
Use `formatCompact(n)` from `core/ui.js`. Outputs `1.2K`, `3.5M` etc. Set `element.title` to the exact value for hover detail.
Use `formatCompact(n)` from `core/ui.ts`. Outputs `1.2K`, `3.5M` etc. Set `element.title` to the exact value for hover detail.
### Preventing layout shift
+22 -16
View File
@@ -8,34 +8,38 @@ Two independent server modes with separate configs, ports, and data directories:
| Mode | Command | Config | Port | API Key | Data |
| ---- | ------- | ------ | ---- | ------- | ---- |
| **Real** | `python -m wled_controller` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` |
| **Demo** | `python -m wled_controller.demo` | `config/demo_config.yaml` | 8081 | `demo` | `data/demo/` |
| **Real** | `python -m ledgrab` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` |
| **Demo** | `python -m ledgrab.demo` | `config/demo_config.yaml` | 8081 | `demo` | `data/demo/` |
Both can run simultaneously on different ports.
Demo mode can also be triggered via the `LEDGRAB_DEMO` environment variable (`true`, `1`, or `yes`). This works with any launch method — Python, Docker (`-e LEDGRAB_DEMO=true`), or the installed app (`set LEDGRAB_DEMO=true` before `LedGrab.bat`).
Both modes can run simultaneously on different ports.
## Restart Procedure
Use the PowerShell restart script — it gracefully shuts the running server down (so stores persist to disk), kills stragglers, launches a detached replacement, and polls the port until it's actually accepting connections. Exit code is 0 on success, 1 if the new server failed to bind the port, 2 on environment errors.
### Real server
Use the PowerShell restart script — it reliably stops only the server process and starts a new detached instance:
```bash
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\wled-screen-controller\server\restart.ps1"
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\led-grab-mixed\led-grab\server\restart.ps1"
```
### Demo server
Find and kill the process on port 8081, then restart:
```bash
# Find PID
powershell -Command "netstat -ano | Select-String ':8081.*LISTEN'"
# Kill it
powershell -Command "Stop-Process -Id <PID> -Force"
# Restart
cd server && python -m wled_controller.demo
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\led-grab-mixed\led-grab\server\restart.ps1" `
-Port 8081 -Module ledgrab.demo -ConfigPath "config\demo_config.yaml"
```
### Useful parameters
- `-Port <int>` / `-Module <name>` — override the target (default: 8080 / `ledgrab`).
- `-StartupTimeoutSec <int>` — how long to wait for the new server to bind the port (default: 30).
- `-ShutdownTimeoutSec <int>` — how long to wait for graceful shutdown before force-killing (default: 15).
- `-Quiet` — suppress progress output.
- `-SkipBrowser:$false` — allow the app to open a browser tab on startup (default: skipped).
**Do NOT use** `Stop-Process -Name python` — it kills unrelated Python processes (VS Code extensions, etc.).
**Do NOT use** bash background `&` jobs — they get killed when the shell session ends.
@@ -43,6 +47,7 @@ cd server && python -m wled_controller.demo
## When to Restart
**Restart required** for changes to:
- API routes (`api/routes/`, `api/schemas/`)
- Core logic (`core/*.py`)
- Configuration (`config.py`)
@@ -50,6 +55,7 @@ cd server && python -m wled_controller.demo
- Data models (`storage/`)
**No restart needed** for:
- Static files (`static/js/`, `static/css/`) — but **must rebuild bundle**: `cd server && npm run build`
- Locale files (`static/locales/*.json`) — loaded by frontend
- Documentation files (`*.md`)
@@ -66,13 +72,13 @@ Auto-reload is disabled (`reload=False` in `main.py`) due to watchfiles causing
2. **New capture engines**: Verify demo mode filtering works — demo engines use `is_demo_mode()` gate in `is_available()`.
3. **New audio engines**: Same as capture engines — `is_available()` must respect `is_demo_mode()`.
4. **New device providers**: Gate discovery with `is_demo_mode()` like `DemoDeviceProvider.discover()`.
5. **New seed data**: Update `server/src/wled_controller/core/demo_seed.py` to include sample entities.
5. **New seed data**: Update `server/src/ledgrab/core/demo_seed.py` to include sample entities.
6. **Frontend indicators**: Demo state exposed via `GET /api/v1/version` -> `demo_mode: bool`. Frontend stores it as `demoMode` in app state and sets `document.body.dataset.demo = 'true'`.
7. **Backup/Restore**: New stores added to `STORE_MAP` in `system.py` automatically work in demo mode since the data directory is already isolated.
### Key files
- Config flag: `server/src/wled_controller/config.py` -> `Config.demo`, `is_demo_mode()`
- Config flag: `server/src/ledgrab/config.py` -> `Config.demo`, `is_demo_mode()`
- Demo engines: `core/capture_engines/demo_engine.py`, `core/audio/demo_engine.py`
- Demo devices: `core/devices/demo_provider.py`
- Seed data: `core/demo_seed.py`
@@ -1,208 +0,0 @@
"""The LED Screen Controller integration."""
from __future__ import annotations
import logging
from datetime import timedelta
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
DOMAIN,
CONF_SERVER_NAME,
CONF_SERVER_URL,
CONF_API_KEY,
DEFAULT_SCAN_INTERVAL,
TARGET_TYPE_KEY_COLORS,
DATA_COORDINATOR,
DATA_WS_MANAGER,
DATA_EVENT_LISTENER,
)
from .coordinator import WLEDScreenControllerCoordinator
from .event_listener import EventStreamListener
from .ws_manager import KeyColorsWebSocketManager
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.LIGHT,
Platform.SWITCH,
Platform.SENSOR,
Platform.NUMBER,
Platform.SELECT,
]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up LED Screen Controller from a config entry."""
server_name = entry.data.get(CONF_SERVER_NAME, "LED Screen Controller")
server_url = entry.data[CONF_SERVER_URL]
api_key = entry.data[CONF_API_KEY]
session = async_get_clientsession(hass)
coordinator = WLEDScreenControllerCoordinator(
hass,
session,
server_url,
api_key,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
)
await coordinator.async_config_entry_first_refresh()
ws_manager = KeyColorsWebSocketManager(hass, server_url, api_key)
event_listener = EventStreamListener(hass, server_url, api_key, coordinator)
await event_listener.start()
# Create device entries for each target and remove stale ones
device_registry = dr.async_get(hass)
current_identifiers: set[tuple[str, str]] = set()
if coordinator.data and "targets" in coordinator.data:
for target_id, target_data in coordinator.data["targets"].items():
info = target_data["info"]
target_type = info.get("target_type", "led")
model = (
"Key Colors Target"
if target_type == TARGET_TYPE_KEY_COLORS
else "LED Target"
)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, target_id)},
name=info.get("name", target_id),
manufacturer=server_name,
model=model,
configuration_url=server_url,
)
current_identifiers.add((DOMAIN, target_id))
# Create a single "Scenes" device for scene preset buttons
scenes_identifier = (DOMAIN, f"{entry.entry_id}_scenes")
scene_presets = coordinator.data.get("scene_presets", []) if coordinator.data else []
if scene_presets:
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={scenes_identifier},
name="Scenes",
manufacturer=server_name,
model="Scene Presets",
configuration_url=server_url,
)
current_identifiers.add(scenes_identifier)
# Remove devices for targets that no longer exist
for device_entry in dr.async_entries_for_config_entry(
device_registry, entry.entry_id
):
if not device_entry.identifiers & current_identifiers:
_LOGGER.info("Removing stale device: %s", device_entry.name)
device_registry.async_remove_device(device_entry.id)
# Store data
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {
DATA_COORDINATOR: coordinator,
DATA_WS_MANAGER: ws_manager,
DATA_EVENT_LISTENER: event_listener,
}
# Track target and scene IDs to detect changes
known_target_ids = set(
coordinator.data.get("targets", {}).keys() if coordinator.data else []
)
known_scene_ids = set(
p["id"] for p in (coordinator.data.get("scene_presets", []) if coordinator.data else [])
)
def _on_coordinator_update() -> None:
"""Manage WS connections and detect target list changes."""
nonlocal known_target_ids, known_scene_ids
if not coordinator.data:
return
targets = coordinator.data.get("targets", {})
# Start/stop WS connections for KC targets based on processing state
for target_id, target_data in targets.items():
info = target_data.get("info", {})
state = target_data.get("state") or {}
if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
if state.get("processing"):
if target_id not in ws_manager._connections:
hass.async_create_task(ws_manager.start_listening(target_id))
else:
if target_id in ws_manager._connections:
hass.async_create_task(ws_manager.stop_listening(target_id))
# Reload if target or scene list changed
current_ids = set(targets.keys())
current_scene_ids = set(
p["id"] for p in coordinator.data.get("scene_presets", [])
)
if current_ids != known_target_ids or current_scene_ids != known_scene_ids:
known_target_ids = current_ids
known_scene_ids = current_scene_ids
_LOGGER.info("Target or scene list changed, reloading integration")
hass.async_create_task(
hass.config_entries.async_reload(entry.entry_id)
)
coordinator.async_add_listener(_on_coordinator_update)
# Register set_leds service (once across all entries)
async def handle_set_leds(call) -> None:
"""Handle the set_leds service call."""
source_id = call.data["source_id"]
segments = call.data["segments"]
# Route to the coordinator that owns this source
for entry_data in hass.data[DOMAIN].values():
coord = entry_data.get(DATA_COORDINATOR)
if not coord or not coord.data:
continue
source_ids = {
s["id"] for s in coord.data.get("css_sources", [])
}
if source_id in source_ids:
await coord.push_segments(source_id, segments)
return
_LOGGER.error("No server found with source_id %s", source_id)
if not hass.services.has_service(DOMAIN, "set_leds"):
hass.services.async_register(
DOMAIN,
"set_leds",
handle_set_leds,
schema=vol.Schema({
vol.Required("source_id"): str,
vol.Required("segments"): list,
}),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
entry_data = hass.data[DOMAIN][entry.entry_id]
await entry_data[DATA_WS_MANAGER].shutdown()
await entry_data[DATA_EVENT_LISTENER].shutdown()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
# Unregister service if no entries remain
if not hass.data[DOMAIN]:
hass.services.async_remove(DOMAIN, "set_leds")
return unload_ok
@@ -1,74 +0,0 @@
"""Button platform for LED Screen Controller — scene preset activation."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, DATA_COORDINATOR
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up scene preset buttons."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities = []
if coordinator.data:
for preset in coordinator.data.get("scene_presets", []):
entities.append(
SceneActivateButton(coordinator, preset, entry.entry_id)
)
async_add_entities(entities)
class SceneActivateButton(CoordinatorEntity, ButtonEntity):
"""Button that activates a scene preset."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
preset: dict[str, Any],
entry_id: str,
) -> None:
"""Initialize the button."""
super().__init__(coordinator)
self._preset_id = preset["id"]
self._entry_id = entry_id
self._attr_unique_id = f"{entry_id}_scene_{preset['id']}"
self._attr_translation_key = "activate_scene"
self._attr_translation_placeholders = {"scene_name": preset["name"]}
self._attr_icon = "mdi:palette"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information — all scene buttons belong to the Scenes device."""
return {"identifiers": {(DOMAIN, f"{self._entry_id}_scenes")}}
@property
def available(self) -> bool:
"""Return if entity is available."""
if not self.coordinator.data:
return False
return self._preset_id in {
p["id"] for p in self.coordinator.data.get("scene_presets", [])
}
async def async_press(self) -> None:
"""Activate the scene preset."""
await self.coordinator.activate_scene(self._preset_id)
@@ -1,127 +0,0 @@
"""Config flow for LED Screen Controller integration."""
from __future__ import annotations
import logging
from typing import Any
from urllib.parse import urlparse, urlunparse
import aiohttp
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, CONF_SERVER_NAME, CONF_SERVER_URL, CONF_API_KEY, DEFAULT_TIMEOUT
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Optional(CONF_SERVER_NAME, default="LED Screen Controller"): str,
vol.Required(CONF_SERVER_URL, default="http://localhost:8080"): str,
vol.Optional(CONF_API_KEY, default=""): str,
}
)
def normalize_url(url: str) -> str:
"""Normalize URL to ensure port is an integer."""
parsed = urlparse(url)
if parsed.port is not None:
netloc = parsed.hostname or "localhost"
port = int(parsed.port)
if port != (443 if parsed.scheme == "https" else 80):
netloc = f"{netloc}:{port}"
parsed = parsed._replace(netloc=netloc)
return urlunparse(parsed)
async def validate_server(
hass: HomeAssistant, server_url: str, api_key: str
) -> dict[str, Any]:
"""Validate server connectivity and API key."""
session = async_get_clientsession(hass)
timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)
# Step 1: Check connectivity via health endpoint (no auth needed)
try:
async with session.get(f"{server_url}/health", timeout=timeout) as resp:
if resp.status != 200:
raise ConnectionError(f"Server returned status {resp.status}")
data = await resp.json()
version = data.get("version", "unknown")
except aiohttp.ClientError as err:
raise ConnectionError(f"Cannot connect to server: {err}") from err
# Step 2: Validate API key via authenticated endpoint (skip if no key and auth not required)
auth_required = data.get("auth_required", True)
if api_key:
headers = {"Authorization": f"Bearer {api_key}"}
try:
async with session.get(
f"{server_url}/api/v1/output-targets",
headers=headers,
timeout=timeout,
) as resp:
if resp.status == 401:
raise PermissionError("Invalid API key")
resp.raise_for_status()
except PermissionError:
raise
except aiohttp.ClientError as err:
raise ConnectionError(f"API request failed: {err}") from err
elif auth_required:
raise PermissionError("Server requires an API key")
return {"version": version}
class WLEDScreenControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for LED Screen Controller."""
VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
server_name = user_input.get(CONF_SERVER_NAME, "LED Screen Controller")
server_url = normalize_url(user_input[CONF_SERVER_URL].rstrip("/"))
api_key = user_input[CONF_API_KEY]
try:
await validate_server(self.hass, server_url, api_key)
await self.async_set_unique_id(server_url)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=server_name,
data={
CONF_SERVER_NAME: server_name,
CONF_SERVER_URL: server_url,
CONF_API_KEY: api_key,
},
)
except ConnectionError as err:
_LOGGER.error("Connection error: %s", err)
errors["base"] = "cannot_connect"
except PermissionError:
errors["base"] = "invalid_api_key"
except Exception as err:
_LOGGER.exception("Unexpected exception: %s", err)
errors["base"] = "unknown"
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
@@ -1,23 +0,0 @@
"""Constants for the LED Screen Controller integration."""
DOMAIN = "wled_screen_controller"
# Configuration
CONF_SERVER_NAME = "server_name"
CONF_SERVER_URL = "server_url"
CONF_API_KEY = "api_key"
# Default values
DEFAULT_SCAN_INTERVAL = 3 # seconds
DEFAULT_TIMEOUT = 10 # seconds
WS_RECONNECT_DELAY = 5 # seconds
WS_MAX_RECONNECT_DELAY = 60 # seconds
# Target types
TARGET_TYPE_LED = "led"
TARGET_TYPE_KEY_COLORS = "key_colors"
# Data keys stored in hass.data[DOMAIN][entry_id]
DATA_COORDINATOR = "coordinator"
DATA_WS_MANAGER = "ws_manager"
DATA_EVENT_LISTENER = "event_listener"
@@ -1,459 +0,0 @@
"""Data update coordinator for LED Screen Controller."""
from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
from typing import Any
import aiohttp
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
DOMAIN,
DEFAULT_TIMEOUT,
TARGET_TYPE_KEY_COLORS,
)
_LOGGER = logging.getLogger(__name__)
class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
"""Class to manage fetching LED Screen Controller data."""
def __init__(
self,
hass: HomeAssistant,
session: aiohttp.ClientSession,
server_url: str,
api_key: str,
update_interval: timedelta,
) -> None:
"""Initialize the coordinator."""
self.server_url = server_url
self.session = session
self.api_key = api_key
self.server_version = "unknown"
self._auth_headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
self._timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=update_interval,
)
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from API."""
try:
async with asyncio.timeout(DEFAULT_TIMEOUT * 3):
if self.server_version == "unknown":
await self._fetch_server_version()
targets_list = await self._fetch_targets()
# Fetch state and metrics for all targets in parallel
targets_data: dict[str, dict[str, Any]] = {}
async def fetch_target_data(target: dict) -> tuple[str, dict]:
target_id = target["id"]
try:
state, metrics = await asyncio.gather(
self._fetch_target_state(target_id),
self._fetch_target_metrics(target_id),
)
except Exception as err:
_LOGGER.warning(
"Failed to fetch data for target %s: %s",
target_id,
err,
)
state = None
metrics = None
result: dict[str, Any] = {
"info": target,
"state": state,
"metrics": metrics,
}
# Fetch rectangles for key_colors targets
if target.get("target_type") == TARGET_TYPE_KEY_COLORS:
kc_settings = target.get("key_colors_settings") or {}
template_id = kc_settings.get("pattern_template_id", "")
if template_id:
result["rectangles"] = await self._fetch_rectangles(
template_id
)
else:
result["rectangles"] = []
else:
result["rectangles"] = []
return target_id, result
results = await asyncio.gather(
*(fetch_target_data(t) for t in targets_list),
return_exceptions=True,
)
for r in results:
if isinstance(r, Exception):
_LOGGER.warning("Target fetch failed: %s", r)
continue
target_id, data = r
targets_data[target_id] = data
# Fetch devices, CSS sources, value sources, and scene presets in parallel
devices_data, css_sources, value_sources, scene_presets = (
await asyncio.gather(
self._fetch_devices(),
self._fetch_css_sources(),
self._fetch_value_sources(),
self._fetch_scene_presets(),
)
)
return {
"targets": targets_data,
"devices": devices_data,
"css_sources": css_sources,
"value_sources": value_sources,
"scene_presets": scene_presets,
"server_version": self.server_version,
}
except asyncio.TimeoutError as err:
raise UpdateFailed(f"Timeout fetching data: {err}") from err
except aiohttp.ClientError as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
async def _fetch_server_version(self) -> None:
"""Fetch server version from health endpoint."""
try:
async with self.session.get(
f"{self.server_url}/health",
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
self.server_version = data.get("version", "unknown")
except Exception as err:
_LOGGER.warning("Failed to fetch server version: %s", err)
self.server_version = "unknown"
async def _fetch_targets(self) -> list[dict[str, Any]]:
"""Fetch all output targets."""
async with self.session.get(
f"{self.server_url}/api/v1/output-targets",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("targets", [])
async def _fetch_target_state(self, target_id: str) -> dict[str, Any]:
"""Fetch target processing state."""
async with self.session.get(
f"{self.server_url}/api/v1/output-targets/{target_id}/state",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
return await resp.json()
async def _fetch_target_metrics(self, target_id: str) -> dict[str, Any]:
"""Fetch target metrics."""
async with self.session.get(
f"{self.server_url}/api/v1/output-targets/{target_id}/metrics",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
return await resp.json()
async def _fetch_rectangles(self, template_id: str) -> list[dict]:
"""Fetch rectangles for a pattern template (no cache — always fresh)."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/pattern-templates/{template_id}",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("rectangles", [])
except Exception as err:
_LOGGER.warning(
"Failed to fetch pattern template %s: %s", template_id, err
)
return []
async def _fetch_devices(self) -> dict[str, dict[str, Any]]:
"""Fetch all devices with capabilities and brightness."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/devices",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
devices = data.get("devices", [])
except Exception as err:
_LOGGER.warning("Failed to fetch devices: %s", err)
return {}
# Fetch brightness for all capable devices in parallel
async def fetch_device_entry(device: dict) -> tuple[str, dict[str, Any]]:
device_id = device["id"]
entry: dict[str, Any] = {"info": device, "brightness": None}
if "brightness_control" in (device.get("capabilities") or []):
try:
async with self.session.get(
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status == 200:
bri_data = await resp.json()
entry["brightness"] = bri_data.get("brightness")
except Exception as err:
_LOGGER.warning(
"Failed to fetch brightness for device %s: %s",
device_id, err,
)
return device_id, entry
results = await asyncio.gather(
*(fetch_device_entry(d) for d in devices),
return_exceptions=True,
)
devices_data: dict[str, dict[str, Any]] = {}
for r in results:
if isinstance(r, Exception):
_LOGGER.warning("Device fetch failed: %s", r)
continue
device_id, entry = r
devices_data[device_id] = entry
return devices_data
async def set_brightness(self, device_id: str, brightness: int) -> None:
"""Set brightness for a device."""
async with self.session.put(
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"brightness": brightness},
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to set brightness for device %s: %s %s",
device_id, resp.status, body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def set_color(self, device_id: str, color: list[int] | None) -> None:
"""Set or clear the static color for a device."""
async with self.session.put(
f"{self.server_url}/api/v1/devices/{device_id}/color",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"color": color},
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to set color for device %s: %s %s",
device_id, resp.status, body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def set_kc_brightness(self, target_id: str, brightness: int) -> None:
"""Set brightness for a Key Colors target (0-255 mapped to 0.0-1.0)."""
brightness_float = round(brightness / 255, 4)
async with self.session.put(
f"{self.server_url}/api/v1/output-targets/{target_id}",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"key_colors_settings": {"brightness": brightness_float}},
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to set KC brightness for target %s: %s %s",
target_id, resp.status, body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def _fetch_css_sources(self) -> list[dict[str, Any]]:
"""Fetch all color strip sources."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/color-strip-sources",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("sources", [])
except Exception as err:
_LOGGER.warning("Failed to fetch CSS sources: %s", err)
return []
async def _fetch_value_sources(self) -> list[dict[str, Any]]:
"""Fetch all value sources."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/value-sources",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("sources", [])
except Exception as err:
_LOGGER.warning("Failed to fetch value sources: %s", err)
return []
async def _fetch_scene_presets(self) -> list[dict[str, Any]]:
"""Fetch all scene presets."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/scene-presets",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("presets", [])
except Exception as err:
_LOGGER.warning("Failed to fetch scene presets: %s", err)
return []
async def push_colors(self, source_id: str, colors: list[list[int]]) -> None:
"""Push flat color array to an api_input CSS source."""
async with self.session.post(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"colors": colors},
timeout=self._timeout,
) as resp:
if resp.status not in (200, 204):
body = await resp.text()
_LOGGER.error(
"Failed to push colors to source %s: %s %s",
source_id, resp.status, body,
)
resp.raise_for_status()
async def push_segments(self, source_id: str, segments: list[dict]) -> None:
"""Push segment data to an api_input CSS source."""
async with self.session.post(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"segments": segments},
timeout=self._timeout,
) as resp:
if resp.status not in (200, 204):
body = await resp.text()
_LOGGER.error(
"Failed to push segments to source %s: %s %s",
source_id, resp.status, body,
)
resp.raise_for_status()
async def activate_scene(self, preset_id: str) -> None:
"""Activate a scene preset."""
async with self.session.post(
f"{self.server_url}/api/v1/scene-presets/{preset_id}/activate",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to activate scene %s: %s %s",
preset_id, resp.status, body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def update_source(self, source_id: str, **kwargs: Any) -> None:
"""Update a color strip source's fields."""
async with self.session.put(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}",
headers={**self._auth_headers, "Content-Type": "application/json"},
json=kwargs,
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to update source %s: %s %s",
source_id, resp.status, body,
)
resp.raise_for_status()
async def update_target(self, target_id: str, **kwargs: Any) -> None:
"""Update an output target's fields."""
async with self.session.put(
f"{self.server_url}/api/v1/output-targets/{target_id}",
headers={**self._auth_headers, "Content-Type": "application/json"},
json=kwargs,
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to update target %s: %s %s",
target_id, resp.status, body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def start_processing(self, target_id: str) -> None:
"""Start processing for a target."""
async with self.session.post(
f"{self.server_url}/api/v1/output-targets/{target_id}/start",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status == 409:
_LOGGER.debug("Target %s already processing", target_id)
elif resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to start target %s: %s %s",
target_id, resp.status, body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def stop_processing(self, target_id: str) -> None:
"""Stop processing for a target."""
async with self.session.post(
f"{self.server_url}/api/v1/output-targets/{target_id}/stop",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status == 409:
_LOGGER.debug("Target %s already stopped", target_id)
elif resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to stop target %s: %s %s",
target_id, resp.status, body,
)
resp.raise_for_status()
await self.async_request_refresh()
@@ -1,95 +0,0 @@
"""WebSocket event listener for server state change notifications."""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import aiohttp
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import WS_RECONNECT_DELAY, WS_MAX_RECONNECT_DELAY
_LOGGER = logging.getLogger(__name__)
class EventStreamListener:
"""Listens to server WS endpoint for state change events.
Triggers a coordinator refresh whenever a target starts or stops processing,
so HAOS entities react near-instantly to external state changes.
"""
def __init__(
self,
hass: HomeAssistant,
server_url: str,
api_key: str,
coordinator: DataUpdateCoordinator,
) -> None:
self._hass = hass
self._server_url = server_url
self._api_key = api_key
self._coordinator = coordinator
self._task: asyncio.Task | None = None
self._shutting_down = False
async def start(self) -> None:
"""Start listening to the event stream."""
self._task = self._hass.async_create_background_task(
self._ws_loop(),
"wled_screen_controller_events",
)
async def _ws_loop(self) -> None:
"""WebSocket connection loop with reconnection."""
delay = WS_RECONNECT_DELAY
session = async_get_clientsession(self._hass)
ws_base = self._server_url.replace("http://", "ws://").replace(
"https://", "wss://"
)
url = f"{ws_base}/api/v1/events/ws?token={self._api_key}"
while not self._shutting_down:
try:
async with session.ws_connect(url) as ws:
delay = WS_RECONNECT_DELAY # reset on successful connect
_LOGGER.debug("Event stream connected")
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
except json.JSONDecodeError:
continue
if data.get("type") == "state_change":
await self._coordinator.async_request_refresh()
elif msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.ERROR,
):
break
except asyncio.CancelledError:
raise
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as err:
_LOGGER.debug("Event stream connection error: %s", err)
except Exception as err:
_LOGGER.error("Unexpected event stream error: %s", err)
if self._shutting_down:
break
await asyncio.sleep(delay)
delay = min(delay * 2, WS_MAX_RECONNECT_DELAY)
async def shutdown(self) -> None:
"""Stop listening."""
self._shutting_down = True
if self._task:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
@@ -1,151 +0,0 @@
"""Light platform for LED Screen Controller (api_input CSS sources)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_RGB_COLOR,
ColorMode,
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, DATA_COORDINATOR
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LED Screen Controller api_input lights."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities = []
if coordinator.data:
for source in coordinator.data.get("css_sources", []):
if source.get("source_type") == "api_input":
entities.append(
ApiInputLight(coordinator, source, entry.entry_id)
)
async_add_entities(entities)
class ApiInputLight(CoordinatorEntity, LightEntity):
"""Representation of an api_input CSS source as a light entity."""
_attr_has_entity_name = True
_attr_color_mode = ColorMode.RGB
_attr_supported_color_modes = {ColorMode.RGB}
_attr_translation_key = "api_input_light"
_attr_icon = "mdi:led-strip-variant"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
source: dict[str, Any],
entry_id: str,
) -> None:
"""Initialize the light."""
super().__init__(coordinator)
self._source_id: str = source["id"]
self._source_name: str = source.get("name", self._source_id)
self._entry_id = entry_id
self._attr_unique_id = f"{self._source_id}_light"
# Restore state from fallback_color
fallback = self._get_fallback_color()
is_off = fallback == [0, 0, 0]
self._is_on: bool = not is_off
self._rgb_color: tuple[int, int, int] = (
(255, 255, 255) if is_off else tuple(fallback) # type: ignore[arg-type]
)
self._brightness: int = 255
@property
def device_info(self) -> dict[str, Any]:
"""Return device information — one virtual device per api_input source."""
return {
"identifiers": {(DOMAIN, self._source_id)},
"name": self._source_name,
"manufacturer": "WLED Screen Controller",
"model": "API Input CSS Source",
}
@property
def name(self) -> str:
"""Return the entity name."""
return self._source_name
@property
def is_on(self) -> bool:
"""Return true if the light is on."""
return self._is_on
@property
def rgb_color(self) -> tuple[int, int, int]:
"""Return the current RGB color."""
return self._rgb_color
@property
def brightness(self) -> int:
"""Return the current brightness (0-255)."""
return self._brightness
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light, optionally setting color and brightness."""
if ATTR_RGB_COLOR in kwargs:
self._rgb_color = kwargs[ATTR_RGB_COLOR]
if ATTR_BRIGHTNESS in kwargs:
self._brightness = kwargs[ATTR_BRIGHTNESS]
# Scale RGB by brightness
scale = self._brightness / 255
r, g, b = self._rgb_color
scaled = [round(r * scale), round(g * scale), round(b * scale)]
await self.coordinator.push_segments(
self._source_id,
[{"start": 0, "length": 9999, "mode": "solid", "color": scaled}],
)
# Update fallback_color so the color persists beyond the timeout
await self.coordinator.update_source(
self._source_id, fallback_color=scaled,
)
self._is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light by pushing black and setting fallback to black."""
off_color = [0, 0, 0]
await self.coordinator.push_segments(
self._source_id,
[{"start": 0, "length": 9999, "mode": "solid", "color": off_color}],
)
await self.coordinator.update_source(
self._source_id, fallback_color=off_color,
)
self._is_on = False
self.async_write_ha_state()
def _get_fallback_color(self) -> list[int]:
"""Read fallback_color from the source config in coordinator data."""
if not self.coordinator.data:
return [0, 0, 0]
for source in self.coordinator.data.get("css_sources", []):
if source.get("id") == self._source_id:
fallback = source.get("fallback_color")
if fallback and len(fallback) >= 3:
return list(fallback[:3])
break
return [0, 0, 0]
@@ -1,12 +0,0 @@
{
"domain": "wled_screen_controller",
"name": "LED Screen Controller",
"codeowners": ["@alexeidolgolyov"],
"config_flow": true,
"dependencies": [],
"documentation": "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed",
"iot_class": "local_push",
"issue_tracker": "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues",
"requirements": ["aiohttp>=3.9.0"],
"version": "0.2.0"
}
@@ -1,169 +0,0 @@
"""Number platform for LED Screen Controller (device & KC target brightness)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, DATA_COORDINATOR, TARGET_TYPE_KEY_COLORS
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LED Screen Controller brightness numbers."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities = []
if coordinator.data and "targets" in coordinator.data:
devices = coordinator.data.get("devices") or {}
for target_id, target_data in coordinator.data["targets"].items():
info = target_data["info"]
if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
# KC target — brightness lives in key_colors_settings
entities.append(
WLEDScreenControllerKCBrightness(
coordinator, target_id, entry.entry_id,
)
)
continue
# LED target — brightness lives on the device
device_id = info.get("device_id", "")
if not device_id:
continue
device_data = devices.get(device_id)
if not device_data:
continue
capabilities = device_data.get("info", {}).get("capabilities") or []
if "brightness_control" not in capabilities or "static_color" in capabilities:
continue
entities.append(
WLEDScreenControllerBrightness(
coordinator, target_id, device_id, entry.entry_id,
)
)
async_add_entities(entities)
class WLEDScreenControllerBrightness(CoordinatorEntity, NumberEntity):
"""Brightness control for an LED device associated with a target."""
_attr_has_entity_name = True
_attr_native_min_value = 0
_attr_native_max_value = 255
_attr_native_step = 1
_attr_mode = NumberMode.SLIDER
_attr_icon = "mdi:brightness-6"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
device_id: str,
entry_id: str,
) -> None:
"""Initialize the brightness number."""
super().__init__(coordinator)
self._target_id = target_id
self._device_id = device_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_brightness"
self._attr_translation_key = "brightness"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def native_value(self) -> float | None:
"""Return the current brightness value."""
if not self.coordinator.data:
return None
device_data = self.coordinator.data.get("devices", {}).get(self._device_id)
if not device_data:
return None
return device_data.get("brightness")
@property
def available(self) -> bool:
"""Return if entity is available."""
if not self.coordinator.data:
return False
targets = self.coordinator.data.get("targets", {})
devices = self.coordinator.data.get("devices", {})
return self._target_id in targets and self._device_id in devices
async def async_set_native_value(self, value: float) -> None:
"""Set brightness value."""
await self.coordinator.set_brightness(self._device_id, int(value))
class WLEDScreenControllerKCBrightness(CoordinatorEntity, NumberEntity):
"""Brightness control for a Key Colors target."""
_attr_has_entity_name = True
_attr_native_min_value = 0
_attr_native_max_value = 255
_attr_native_step = 1
_attr_mode = NumberMode.SLIDER
_attr_icon = "mdi:brightness-6"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
"""Initialize the KC brightness number."""
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_brightness"
self._attr_translation_key = "brightness"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def native_value(self) -> float | None:
"""Return the current brightness value (0-255)."""
if not self.coordinator.data:
return None
target_data = self.coordinator.data.get("targets", {}).get(self._target_id)
if not target_data:
return None
kc_settings = target_data.get("info", {}).get("key_colors_settings") or {}
brightness_float = kc_settings.get("brightness", 1.0)
return round(brightness_float * 255)
@property
def available(self) -> bool:
"""Return if entity is available."""
if not self.coordinator.data:
return False
return self._target_id in self.coordinator.data.get("targets", {})
async def async_set_native_value(self, value: float) -> None:
"""Set brightness value."""
await self.coordinator.set_kc_brightness(self._target_id, int(value))
@@ -1,177 +0,0 @@
"""Select platform for LED Screen Controller (CSS source & brightness source)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, DATA_COORDINATOR, TARGET_TYPE_KEY_COLORS
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
NONE_OPTION = "— None —"
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LED Screen Controller select entities."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities: list[SelectEntity] = []
if coordinator.data and "targets" in coordinator.data:
for target_id, target_data in coordinator.data["targets"].items():
info = target_data["info"]
# Only LED targets
if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
continue
entities.append(
CSSSourceSelect(coordinator, target_id, entry.entry_id)
)
entities.append(
BrightnessSourceSelect(coordinator, target_id, entry.entry_id)
)
async_add_entities(entities)
class CSSSourceSelect(CoordinatorEntity, SelectEntity):
"""Select entity for choosing a color strip source for an LED target."""
_attr_has_entity_name = True
_attr_icon = "mdi:palette"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_css_source"
self._attr_translation_key = "color_strip_source"
@property
def device_info(self) -> dict[str, Any]:
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def options(self) -> list[str]:
if not self.coordinator.data:
return []
sources = self.coordinator.data.get("css_sources") or []
return [s["name"] for s in sources]
@property
def current_option(self) -> str | None:
if not self.coordinator.data:
return None
target_data = self.coordinator.data.get("targets", {}).get(self._target_id)
if not target_data:
return None
current_id = target_data["info"].get("color_strip_source_id", "")
sources = self.coordinator.data.get("css_sources") or []
for s in sources:
if s["id"] == current_id:
return s["name"]
return None
@property
def available(self) -> bool:
if not self.coordinator.data:
return False
return self._target_id in self.coordinator.data.get("targets", {})
async def async_select_option(self, option: str) -> None:
source_id = self._name_to_id_map().get(option)
if source_id is None:
_LOGGER.error("CSS source not found: %s", option)
return
await self.coordinator.update_target(
self._target_id, color_strip_source_id=source_id
)
def _name_to_id_map(self) -> dict[str, str]:
sources = (self.coordinator.data or {}).get("css_sources") or []
return {s["name"]: s["id"] for s in sources}
class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
"""Select entity for choosing a brightness value source for an LED target."""
_attr_has_entity_name = True
_attr_icon = "mdi:brightness-auto"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_brightness_source"
self._attr_translation_key = "brightness_source"
@property
def device_info(self) -> dict[str, Any]:
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def options(self) -> list[str]:
if not self.coordinator.data:
return [NONE_OPTION]
sources = self.coordinator.data.get("value_sources") or []
return [NONE_OPTION] + [s["name"] for s in sources]
@property
def current_option(self) -> str | None:
if not self.coordinator.data:
return None
target_data = self.coordinator.data.get("targets", {}).get(self._target_id)
if not target_data:
return None
current_id = target_data["info"].get("brightness_value_source_id", "")
if not current_id:
return NONE_OPTION
sources = self.coordinator.data.get("value_sources") or []
for s in sources:
if s["id"] == current_id:
return s["name"]
return NONE_OPTION
@property
def available(self) -> bool:
if not self.coordinator.data:
return False
return self._target_id in self.coordinator.data.get("targets", {})
async def async_select_option(self, option: str) -> None:
if option == NONE_OPTION:
source_id = ""
else:
name_map = {
s["name"]: s["id"]
for s in (self.coordinator.data or {}).get("value_sources") or []
}
source_id = name_map.get(option)
if source_id is None:
_LOGGER.error("Value source not found: %s", option)
return
await self.coordinator.update_target(
self._target_id, brightness_value_source_id=source_id
)
@@ -1,273 +0,0 @@
"""Sensor platform for LED Screen Controller."""
from __future__ import annotations
import logging
from collections.abc import Callable
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
DOMAIN,
TARGET_TYPE_KEY_COLORS,
DATA_COORDINATOR,
DATA_WS_MANAGER,
)
from .coordinator import WLEDScreenControllerCoordinator
from .ws_manager import KeyColorsWebSocketManager
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LED Screen Controller sensors."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
ws_manager: KeyColorsWebSocketManager = data[DATA_WS_MANAGER]
entities: list[SensorEntity] = []
if coordinator.data and "targets" in coordinator.data:
for target_id, target_data in coordinator.data["targets"].items():
entities.append(
WLEDScreenControllerFPSSensor(coordinator, target_id, entry.entry_id)
)
entities.append(
WLEDScreenControllerStatusSensor(
coordinator, target_id, entry.entry_id
)
)
# Add color sensors for Key Colors targets
info = target_data["info"]
if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
rectangles = target_data.get("rectangles", [])
for rect in rectangles:
entities.append(
WLEDScreenControllerColorSensor(
coordinator=coordinator,
ws_manager=ws_manager,
target_id=target_id,
rectangle_name=rect["name"],
entry_id=entry.entry_id,
)
)
async_add_entities(entities)
class WLEDScreenControllerFPSSensor(CoordinatorEntity, SensorEntity):
"""FPS sensor for a LED Screen Controller target."""
_attr_has_entity_name = True
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_unit_of_measurement = "FPS"
_attr_icon = "mdi:speedometer"
_attr_suggested_display_precision = 1
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_fps"
self._attr_translation_key = "fps"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def native_value(self) -> float | None:
"""Return the FPS value."""
target_data = self._get_target_data()
if not target_data or not target_data.get("state"):
return None
state = target_data["state"]
if not state.get("processing"):
return None
return state.get("fps_actual")
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return additional attributes."""
target_data = self._get_target_data()
if not target_data or not target_data.get("state"):
return {}
return {"fps_target": target_data["state"].get("fps_target")}
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._get_target_data() is not None
def _get_target_data(self) -> dict[str, Any] | None:
if not self.coordinator.data:
return None
return self.coordinator.data.get("targets", {}).get(self._target_id)
class WLEDScreenControllerStatusSensor(CoordinatorEntity, SensorEntity):
"""Status sensor for a LED Screen Controller target."""
_attr_has_entity_name = True
_attr_icon = "mdi:information-outline"
_attr_device_class = SensorDeviceClass.ENUM
_attr_options = ["processing", "idle", "error", "unavailable"]
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_status"
self._attr_translation_key = "status"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def native_value(self) -> str:
"""Return the status."""
target_data = self._get_target_data()
if not target_data:
return "unavailable"
state = target_data.get("state")
if not state:
return "unavailable"
if state.get("processing"):
errors = state.get("errors", [])
if errors:
return "error"
return "processing"
return "idle"
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._get_target_data() is not None
def _get_target_data(self) -> dict[str, Any] | None:
if not self.coordinator.data:
return None
return self.coordinator.data.get("targets", {}).get(self._target_id)
class WLEDScreenControllerColorSensor(CoordinatorEntity, SensorEntity):
"""Color sensor reporting the extracted screen color for a Key Colors rectangle."""
_attr_has_entity_name = True
_attr_icon = "mdi:palette"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
ws_manager: KeyColorsWebSocketManager,
target_id: str,
rectangle_name: str,
entry_id: str,
) -> None:
"""Initialize the color sensor."""
super().__init__(coordinator)
self._target_id = target_id
self._rectangle_name = rectangle_name
self._ws_manager = ws_manager
self._entry_id = entry_id
self._unregister_ws: Callable[[], None] | None = None
sanitized = rectangle_name.lower().replace(" ", "_").replace("-", "_")
self._attr_unique_id = f"{target_id}_{sanitized}_color"
self._attr_translation_key = "rectangle_color"
self._attr_translation_placeholders = {"rectangle_name": rectangle_name}
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {"identifiers": {(DOMAIN, self._target_id)}}
async def async_added_to_hass(self) -> None:
"""Register WS callback when entity is added."""
await super().async_added_to_hass()
self._unregister_ws = self._ws_manager.register_callback(
self._target_id, self._handle_color_update
)
async def async_will_remove_from_hass(self) -> None:
"""Unregister WS callback when entity is removed."""
if self._unregister_ws:
self._unregister_ws()
self._unregister_ws = None
await super().async_will_remove_from_hass()
def _handle_color_update(self, colors: dict) -> None:
"""Handle incoming color update from WebSocket."""
if self._rectangle_name in colors:
self.async_write_ha_state()
@property
def native_value(self) -> str | None:
"""Return the hex color string (e.g. #FF8800)."""
color = self._get_color()
if color is None:
return None
return f"#{color['r']:02X}{color['g']:02X}{color['b']:02X}"
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return r, g, b, brightness as attributes."""
color = self._get_color()
if color is None:
return {}
r, g, b = color["r"], color["g"], color["b"]
brightness = int(0.299 * r + 0.587 * g + 0.114 * b)
return {
"r": r,
"g": g,
"b": b,
"brightness": brightness,
"rgb_color": [r, g, b],
}
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._get_target_data() is not None
def _get_color(self) -> dict[str, int] | None:
"""Get the current color for this rectangle from WS manager."""
target_data = self._get_target_data()
if not target_data or not target_data.get("state"):
return None
if not target_data["state"].get("processing"):
return None
colors = self._ws_manager.get_latest_colors(self._target_id)
return colors.get(self._rectangle_name)
def _get_target_data(self) -> dict[str, Any] | None:
if not self.coordinator.data:
return None
return self.coordinator.data.get("targets", {}).get(self._target_id)
@@ -1,19 +0,0 @@
set_leds:
name: Set LEDs
description: Push segment data to an api_input color strip source
fields:
source_id:
name: Source ID
description: The api_input CSS source ID (e.g., css_abc12345)
required: true
selector:
text:
segments:
name: Segments
description: >
List of segment objects. Each segment has: start (int), length (int),
mode ("solid"/"per_pixel"/"gradient"), color ([R,G,B] for solid),
colors ([[R,G,B],...] for per_pixel/gradient)
required: true
selector:
object:
@@ -1,91 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Set up LED Screen Controller",
"description": "Enter the URL and API key for your LED Screen Controller server.",
"data": {
"server_name": "Server Name",
"server_url": "Server URL",
"api_key": "API Key"
},
"data_description": {
"server_name": "Display name for this server in Home Assistant",
"server_url": "URL of your LED Screen Controller server (e.g., http://192.168.1.100:8080)",
"api_key": "API key from your server's configuration file"
}
}
},
"error": {
"cannot_connect": "Failed to connect to server.",
"invalid_api_key": "Invalid API key.",
"unknown": "Unexpected error occurred."
},
"abort": {
"already_configured": "This server is already configured."
}
},
"entity": {
"button": {
"activate_scene": {
"name": "{scene_name}"
}
},
"light": {
"api_input_light": {
"name": "Light"
}
},
"switch": {
"processing": {
"name": "Processing"
}
},
"sensor": {
"fps": {
"name": "FPS"
},
"status": {
"name": "Status",
"state": {
"processing": "Processing",
"idle": "Idle",
"error": "Error",
"unavailable": "Unavailable"
}
},
"rectangle_color": {
"name": "{rectangle_name} Color"
}
},
"number": {
"brightness": {
"name": "Brightness"
}
},
"select": {
"color_strip_source": {
"name": "Color Strip Source"
},
"brightness_source": {
"name": "Brightness Source"
}
}
},
"services": {
"set_leds": {
"name": "Set LEDs",
"description": "Push segment data to an api_input color strip source.",
"fields": {
"source_id": {
"name": "Source ID",
"description": "The api_input CSS source ID (e.g., css_abc12345)."
},
"segments": {
"name": "Segments",
"description": "List of segment objects with start, length, mode, and color/colors fields."
}
}
}
}
}
@@ -1,109 +0,0 @@
"""Switch platform for LED Screen Controller."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, DATA_COORDINATOR
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LED Screen Controller switches."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities = []
if coordinator.data and "targets" in coordinator.data:
for target_id, target_data in coordinator.data["targets"].items():
entities.append(
WLEDScreenControllerSwitch(coordinator, target_id, entry.entry_id)
)
async_add_entities(entities)
class WLEDScreenControllerSwitch(CoordinatorEntity, SwitchEntity):
"""Representation of a LED Screen Controller target processing switch."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_processing"
self._attr_translation_key = "processing"
self._attr_icon = "mdi:television-ambient-light"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def is_on(self) -> bool:
"""Return true if processing is active."""
target_data = self._get_target_data()
if not target_data or not target_data.get("state"):
return False
return target_data["state"].get("processing", False)
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._get_target_data() is not None
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return additional state attributes."""
target_data = self._get_target_data()
if not target_data:
return {}
attrs: dict[str, Any] = {"target_id": self._target_id}
state = target_data.get("state") or {}
metrics = target_data.get("metrics") or {}
if state:
attrs["fps_target"] = state.get("fps_target")
attrs["fps_actual"] = state.get("fps_actual")
if metrics:
attrs["frames_processed"] = metrics.get("frames_processed")
attrs["errors_count"] = metrics.get("errors_count")
attrs["uptime_seconds"] = metrics.get("uptime_seconds")
return attrs
async def async_turn_on(self, **kwargs: Any) -> None:
"""Start processing."""
await self.coordinator.start_processing(self._target_id)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Stop processing."""
await self.coordinator.stop_processing(self._target_id)
def _get_target_data(self) -> dict[str, Any] | None:
"""Get target data from coordinator."""
if not self.coordinator.data:
return None
return self.coordinator.data.get("targets", {}).get(self._target_id)
@@ -1,75 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Set up LED Screen Controller",
"description": "Enter the URL and API key for your LED Screen Controller server.",
"data": {
"server_name": "Server Name",
"server_url": "Server URL",
"api_key": "API Key"
},
"data_description": {
"server_name": "Display name for this server in Home Assistant",
"server_url": "URL of your LED Screen Controller server (e.g., http://192.168.1.100:8080)",
"api_key": "API key from your server's configuration file"
}
}
},
"error": {
"cannot_connect": "Failed to connect to server.",
"invalid_api_key": "Invalid API key.",
"unknown": "Unexpected error occurred."
},
"abort": {
"already_configured": "This server is already configured."
}
},
"entity": {
"button": {
"activate_scene": {
"name": "{scene_name}"
}
},
"light": {
"api_input_light": {
"name": "Light"
}
},
"switch": {
"processing": {
"name": "Processing"
}
},
"sensor": {
"fps": {
"name": "FPS"
},
"status": {
"name": "Status",
"state": {
"processing": "Processing",
"idle": "Idle",
"error": "Error",
"unavailable": "Unavailable"
}
},
"rectangle_color": {
"name": "{rectangle_name} Color"
}
},
"number": {
"brightness": {
"name": "Brightness"
}
},
"select": {
"color_strip_source": {
"name": "Color Strip Source"
},
"brightness_source": {
"name": "Brightness Source"
}
}
}
}
@@ -1,75 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Настройка LED Screen Controller",
"description": "Введите URL и API-ключ вашего сервера LED Screen Controller.",
"data": {
"server_name": "Имя сервера",
"server_url": "URL сервера",
"api_key": "API-ключ"
},
"data_description": {
"server_name": "Отображаемое имя сервера в Home Assistant",
"server_url": "URL сервера LED Screen Controller (например, http://192.168.1.100:8080)",
"api_key": "API-ключ из конфигурационного файла сервера"
}
}
},
"error": {
"cannot_connect": "Не удалось подключиться к серверу.",
"invalid_api_key": "Неверный API-ключ.",
"unknown": "Произошла непредвиденная ошибка."
},
"abort": {
"already_configured": "Этот сервер уже настроен."
}
},
"entity": {
"button": {
"activate_scene": {
"name": "{scene_name}"
}
},
"light": {
"api_input_light": {
"name": "Подсветка"
}
},
"switch": {
"processing": {
"name": "Обработка"
}
},
"sensor": {
"fps": {
"name": "FPS"
},
"status": {
"name": "Статус",
"state": {
"processing": "Обработка",
"idle": "Ожидание",
"error": "Ошибка",
"unavailable": "Недоступен"
}
},
"rectangle_color": {
"name": "{rectangle_name} Цвет"
}
},
"number": {
"brightness": {
"name": "Яркость"
}
},
"select": {
"color_strip_source": {
"name": "Источник цветовой полосы"
},
"brightness_source": {
"name": "Источник яркости"
}
}
}
}
@@ -1,136 +0,0 @@
"""WebSocket connection manager for Key Colors target color streams."""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
from collections.abc import Callable
from typing import Any
import aiohttp
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import WS_RECONNECT_DELAY, WS_MAX_RECONNECT_DELAY
_LOGGER = logging.getLogger(__name__)
class KeyColorsWebSocketManager:
"""Manages WebSocket connections for Key Colors target color streams."""
def __init__(
self,
hass: HomeAssistant,
server_url: str,
api_key: str,
) -> None:
self._hass = hass
self._server_url = server_url
self._api_key = api_key
self._connections: dict[str, asyncio.Task] = {}
self._callbacks: dict[str, list[Callable]] = {}
self._latest_colors: dict[str, dict[str, dict[str, int]]] = {}
self._shutting_down = False
def _get_ws_url(self, target_id: str) -> str:
"""Build WebSocket URL for a target."""
ws_base = self._server_url.replace("http://", "ws://").replace(
"https://", "wss://"
)
return f"{ws_base}/api/v1/output-targets/{target_id}/ws?token={self._api_key}"
async def start_listening(self, target_id: str) -> None:
"""Start WebSocket connection for a target."""
if target_id in self._connections:
return
task = self._hass.async_create_background_task(
self._ws_loop(target_id),
f"wled_screen_controller_ws_{target_id}",
)
self._connections[target_id] = task
async def stop_listening(self, target_id: str) -> None:
"""Stop WebSocket connection for a target."""
task = self._connections.pop(target_id, None)
if task:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
self._latest_colors.pop(target_id, None)
def register_callback(
self, target_id: str, callback: Callable
) -> Callable[[], None]:
"""Register a callback for color updates. Returns unregister function."""
self._callbacks.setdefault(target_id, []).append(callback)
def unregister() -> None:
cbs = self._callbacks.get(target_id)
if cbs and callback in cbs:
cbs.remove(callback)
return unregister
def get_latest_colors(self, target_id: str) -> dict[str, dict[str, int]]:
"""Get latest colors for a target."""
return self._latest_colors.get(target_id, {})
async def _ws_loop(self, target_id: str) -> None:
"""WebSocket connection loop with reconnection."""
delay = WS_RECONNECT_DELAY
session = async_get_clientsession(self._hass)
while not self._shutting_down:
try:
url = self._get_ws_url(target_id)
async with session.ws_connect(url) as ws:
delay = WS_RECONNECT_DELAY # reset on successful connect
_LOGGER.debug("WS connected for target %s", target_id)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
self._handle_message(target_id, msg.data)
elif msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.ERROR,
):
break
except asyncio.CancelledError:
raise
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as err:
_LOGGER.debug("WS connection error for %s: %s", target_id, err)
except Exception as err:
_LOGGER.error("Unexpected WS error for %s: %s", target_id, err)
if self._shutting_down:
break
await asyncio.sleep(delay)
delay = min(delay * 2, WS_MAX_RECONNECT_DELAY)
def _handle_message(self, target_id: str, raw: str) -> None:
"""Handle incoming WebSocket message."""
try:
data = json.loads(raw)
except json.JSONDecodeError:
return
if data.get("type") != "colors_update":
return
colors: dict[str, Any] = data.get("colors", {})
self._latest_colors[target_id] = colors
for cb in self._callbacks.get(target_id, []):
try:
cb(colors)
except Exception:
_LOGGER.exception("Error in WS color callback for %s", target_id)
async def shutdown(self) -> None:
"""Stop all WebSocket connections."""
self._shutting_down = True
for target_id in list(self._connections):
await self.stop_listening(target_id)
+2 -2
View File
@@ -1,6 +1,6 @@
# WLED Screen Controller API Documentation
# LedGrab API Documentation
Complete REST API reference for the WLED Screen Controller server.
Complete REST API reference for the LedGrab server.
**Base URL:** `http://localhost:8080`
**API Version:** v1
+109
View File
@@ -0,0 +1,109 @@
# BLE LED Controllers — Investigation & Implementation Notes
Reference for anyone touching the BLE device provider (`server/src/ledgrab/core/devices/ble_*`). Captures the protocol quirks, Windows/bleak traps, and hardware lockdown we hit while bringing up SP110E / Triones / Zengge / Govee support.
## Architecture
```
BLEDeviceProvider → BLEClient → BLETransport (desktop: bleak, Android: Kotlin BleBridge via Chaquopy)
└─ BLEProtocol (family-specific wire bytes: sp110e.py, triones.py, zengge.py, govee.py)
```
- One `BLEProtocol` dataclass per controller family. Each supplies GATT UUIDs, write type (with/without response), `encode_color` / `encode_power` functions, name prefixes for discovery, and an optional `init_writes` handshake sequence.
- `BLEClient` is whole-strip only. `send_pixels()` averages incoming pixel arrays and emits one solid color per frame — none of these protocols support per-pixel streaming.
- Discovery auto-detects the family via advertised name prefix first, falls back to service UUID matching. The detected family is returned on `DiscoveredDevice.ble_family` and preselected in the UI.
- The settings modal lets users change the family after creation — wrong family → writes go to a characteristic the device ignores → strip stays dark.
## Protocol Quirks
### SP110E / SP108E (critical handshake)
The controller **silently tears the GATT link down within ~1 second of connect** unless a two-write handshake arrives immediately:
```
Write 01 00 → characteristic FFE2
Write 01 B7 E3 D5 → characteristic FFE1
```
Without this, the first real write later hangs for 30 s because bleak thinks the link is up but the peripheral has already dropped it. We carry these writes in `PROTOCOL.init_writes` and execute them from `BLEClient.connect()` right after GATT open.
Color frame is **4 bytes** (`RR GG BB 1E`), not 5 — the earlier implementation had a stray `0x00` padding byte that the device tolerated but isn't documented.
Source: [mbullington's reverse-engineering gist](https://gist.github.com/mbullington/37957501a07ad065b67d4e8d39bfe012).
### Triones / Zengge / Govee
No init handshake required. Color frames and command bytes documented inline in each protocol module. Notable: Zengge and SP110E share service UUID `FFE0/FFE1`, so name-based identification is the only reliable way to tell them apart. In `_register_builtins()`, SP110E is registered first so it wins the `identify_family_by_service_uuids` tie by default — change this if the user base flips.
## bleak + Windows WinRT Traps
These bit us hard. All are now worked around, but future BLE work should keep them in mind.
### 1. `asyncio.wait_for` hangs forever on WinRT
`BleakClient.connect()` / `write_gatt_char()` wrap WinRT `IAsyncOperation`s. When asyncio tries to cancel them (as `wait_for` does on timeout), the WinRT task **never finishes cancelling**, so `wait_for` itself blocks forever while awaiting the cancellation. Symptom: log stops with no timeout error, process is alive but wedged.
**Fix**: `_bounded_await()` in [ble_transport.py](../server/src/ledgrab/core/devices/ble_transport.py) uses `asyncio.wait()` instead, which returns on timeout without awaiting pending tasks. Orphans the hanging WinRT task but frees the caller.
### 2. Connect by raw MAC string fails on Windows
Passing `BleakClient("AA:BB:CC:DD:EE:FF")` makes WinRT guess the address type (public vs random static vs random resolvable). Guesses wrong → connect silently times out. Symptom: `TimeoutError: BLE connect to ... exceeded 10.0s` with no other signal.
**Fix**: Always pre-scan with `BleakScanner.find_device_by_address()` and pass the returned `BLEDevice` object to `BleakClient`. Costs ~400 ms but makes connect reliable.
### 3. Client-side fetch timeout too short for BLE target start
The target-start endpoint does a ~5 s pre-scan + up to 10 s GATT connect + init handshake. Default `fetchWithAuth` has a 10 s timeout and 3× retry, so the UI was aborting and retrying concurrent `/start` requests into the server.
**Fix**: `startTargetProcessing` overrides `timeout: 30000, retry: false`.
### 4. `Start-Process -WindowStyle Hidden` from bash/WSL strips handles
When `restart.ps1` is invoked from Git-Bash / WSL, `Start-Process` inherited handles cause the child uvicorn to exit immediately. Stream redirection fixes it.
**Fix**: `restart.ps1` always uses `-RedirectStandardOutput`/`-RedirectStandardError` to a temp log. Failed startups dump the stderr tail to the caller so root cause is visible.
## Vendor Lockdown (the dead end)
Some controllers — notably the one we tested, advertising as `AlexTable` at `16:61:05:70:68:44`**only accept connections from the vendor phone app**. Diagnostic sequence:
| Test | Result | Meaning |
| --- | --- | --- |
| LedGrab `BleakClient.connect()` | 10 s timeout | Windows can't connect |
| Windows "Bluetooth LE Explorer" | Hangs on connect | Same Windows stack as bleak — not our bug |
| Phone **OS** Bluetooth Settings | Can't connect | Phone OS uses generic BLE stack — also fails |
| Phone **LED Hue** app | Connects fine | Vendor app is the *only* working client |
At this point, further Windows/bleak tweaks have no effect. The peripheral firmware rejects generic GATT connects and only stays connected when the LED Hue app emits its vendor-specific handshake. To unlock such a controller from LedGrab you'd need to:
1. Enable **Developer Options → Bluetooth HCI snoop log** on Android.
2. Reproduce the LED Hue flow (connect → color change → disconnect).
3. `adb bugreport bugreport.zip`; extract `btsnoop_hci.log`.
4. Open in Wireshark; identify the vendor handshake bytes written during connect.
5. Add them to the protocol's `init_writes`.
Alternatively, replace the BLE controller hardware with **WLED on ESP32** — $3, fully supported, vastly more capable.
## Frontend
- BLE family picker uses the project's shared `IconSelect` grid (project rule — see [CLAUDE.md](../CLAUDE.md): "NEVER use plain HTML `<select>`").
- Registry in `device-discovery.ts` is keyed by element ID so both the add-device and settings modals get their own IconSelect instance. Helpers: `ensureBleFamilyIconSelect(selectId, onChange?)` / `destroyBleFamilyIconSelect(selectId)`.
- Govee AES key row is conditionally visible: only shows when the selected family is `govee`.
## HAOS Integration Pair
The sister repo `ledgrab-haos-integration` had its own WebSocket auth bug that surfaced during this session — the integration still used the deprecated `?token=<key>` query param instead of the new first-message handshake. Fixed in [v0.2.1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab-haos-integration). Unrelated to BLE but shared debugging time.
## Tests
`server/tests/test_ble_protocols.py` and `server/tests/test_ble_client.py` use a `FakeTransport` that logs every write with its `char_uuid`, so protocol wire formats and the init handshake are unit-testable without hardware or bleak installed. New protocol additions should extend these.
## Files
- [ble_client.py](../server/src/ledgrab/core/devices/ble_client.py) — provider-facing class; runs init handshake on connect; reconnect backoff.
- [ble_transport.py](../server/src/ledgrab/core/devices/ble_transport.py) — bleak desktop transport; `_bounded_await` helper; per-write char override.
- [android_ble_transport.py](../server/src/ledgrab/core/devices/android_ble_transport.py) — Chaquopy/Kotlin transport; currently ignores `char_uuid` override (bridge binds a single write characteristic).
- [ble_provider.py](../server/src/ledgrab/core/devices/ble_provider.py) — discovery, family detection, `set_color` / `set_power` short-lived sessions.
- [ble_protocols/](../server/src/ledgrab/core/devices/ble_protocols/) — one file per family (pure byte-encoding functions, no BLE deps).
- [BleBridge.kt](../android/app/src/main/java/com/ledgrab/android/BleBridge.kt) — Android-side BLE GATT wrapper exposed to Python via Chaquopy.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
{
"name": "WLED Screen Controller",
"render_readme": true,
"country": ["US"],
"homeassistant": "2023.1.0"
}
-30
View File
@@ -1,30 +0,0 @@
# Feature Context: Demo Mode
## Current State
Starting implementation. No changes made yet.
## Key Architecture Notes
- `EngineRegistry` (class-level dict) holds capture engines, auto-registered in `capture_engines/__init__.py`
- `AudioEngineRegistry` (class-level dict) holds audio engines, auto-registered in `audio/__init__.py`
- `LEDDeviceProvider` instances registered via `register_provider()` in `led_client.py`
- Already has `MockDeviceProvider` + `MockClient` (device type "mock") for testing
- Config is `pydantic_settings.BaseSettings` in `config.py`, loaded from YAML + env vars
- Frontend header in `templates/index.html` line 27-31: title + version badge
- Frontend bundle: `cd server && npm run build` (esbuild)
- Data stored as JSON in `data/` directory, paths configured via `StorageConfig`
## Temporary Workarounds
- None yet
## Cross-Phase Dependencies
- Phase 1 (config flag) is foundational — all other phases depend on `is_demo_mode()`
- Phase 2 & 3 (engines) can be done independently of each other
- Phase 4 (seed data) depends on knowing what entities to create, which is informed by phases 2-3
- Phase 5 (frontend) depends on the system info API field from phase 1
- Phase 6 (engine resolution) depends on engines existing from phases 2-3
## Implementation Notes
- Demo mode activated via `WLED_DEMO=true` env var or `demo: true` in YAML config
- Isolated data directory `data/demo/` keeps demo entities separate from real config
- Demo engines use `ENGINE_TYPE = "demo"` and are always registered but return `is_available() = True` only in demo mode
- The existing `MockDeviceProvider`/`MockClient` can be reused or extended for demo device output
-44
View File
@@ -1,44 +0,0 @@
# Feature: Demo Mode
**Branch:** `feature/demo-mode`
**Base branch:** `master`
**Created:** 2026-03-20
**Status:** 🟡 In Progress
**Strategy:** Big Bang
**Mode:** Automated
**Execution:** Orchestrator
## Summary
Add a demo mode that allows users to explore and test the app without real hardware. Virtual capture engines, audio engines, and device providers replace real hardware. An isolated data directory with seed data provides a fully populated sandbox. A visual indicator in the UI makes it clear the app is running in demo mode.
## Build & Test Commands
- **Build (frontend):** `cd server && npm run build`
- **Typecheck (frontend):** `cd server && npm run typecheck`
- **Test (backend):** `cd server && python -m pytest ../tests/ -x`
- **Server start:** `cd server && python -m wled_controller.main`
## Phases
- [x] Phase 1: Demo Mode Config & Flag [domain: backend] → [subplan](./phase-1-config-flag.md)
- [x] Phase 2: Virtual Capture Engine [domain: backend] → [subplan](./phase-2-virtual-capture-engine.md)
- [x] Phase 3: Virtual Audio Engine [domain: backend] → [subplan](./phase-3-virtual-audio-engine.md)
- [x] Phase 4: Demo Device Provider & Seed Data [domain: backend] → [subplan](./phase-4-demo-device-seed-data.md)
- [x] Phase 5: Frontend Demo Indicator & Sandbox UX [domain: fullstack] → [subplan](./phase-5-frontend-demo-ux.md)
- [x] Phase 6: Demo-only Engine Resolution [domain: backend] → [subplan](./phase-6-engine-resolution.md)
## Phase Progress Log
| Phase | Domain | Status | Review | Build | Committed |
|-------|--------|--------|--------|-------|-----------|
| Phase 1: Config & Flag | backend | ✅ Done | ✅ | ✅ | ⬜ |
| Phase 2: Virtual Capture Engine | backend | ✅ Done | ✅ | ✅ | ⬜ |
| Phase 3: Virtual Audio Engine | backend | ✅ Done | ✅ | ✅ | ⬜ |
| Phase 4: Demo Device & Seed Data | backend | ✅ Done | ✅ | ✅ | ⬜ |
| Phase 5: Frontend Demo UX | fullstack | ✅ Done | ✅ | ✅ | ⬜ |
| Phase 6: Engine Resolution | backend | ✅ Done | ✅ | ✅ | ⬜ |
## Final Review
- [ ] Comprehensive code review
- [ ] Full build passes
- [ ] Full test suite passes
- [ ] Merged to `master`
-42
View File
@@ -1,42 +0,0 @@
# Phase 1: Demo Mode Config & Flag
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Add a `demo` boolean flag to the application configuration and expose it to the frontend via the system info API. When demo mode is active, the server uses an isolated data directory so demo entities don't pollute real user data.
## Tasks
- [ ] Task 1: Add `demo: bool = False` field to `Config` class in `config.py`
- [ ] Task 2: Add a module-level helper `is_demo_mode() -> bool` in `config.py` for easy import
- [ ] Task 3: Modify `StorageConfig` path resolution: when `demo=True`, prefix all storage paths with `data/demo/` instead of `data/`
- [ ] Task 4: Expose `demo_mode: bool` in the existing `GET /api/v1/system/info` endpoint response
- [ ] Task 5: Add `WLED_DEMO=true` env var support (already handled by pydantic-settings env prefix `WLED_`)
## Files to Modify/Create
- `server/src/wled_controller/config.py` — Add `demo` field, `is_demo_mode()` helper, storage path override
- `server/src/wled_controller/api/routes/system.py` — Add `demo_mode` to system info response
- `server/src/wled_controller/api/schemas/system.py` — Add `demo_mode` field to response schema
## Acceptance Criteria
- `Config(demo=True)` is accepted; default is `False`
- `WLED_DEMO=true` activates demo mode
- `is_demo_mode()` returns the correct value
- When demo mode is on, all storage files resolve under `data/demo/`
- `GET /api/v1/system/info` includes `demo_mode: true/false`
## Notes
- The env var will be `WLED_DEMO` because of `env_prefix="WLED_"` in pydantic-settings
- Storage path override should happen at `Config` construction time, not lazily
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -1,48 +0,0 @@
# Phase 2: Virtual Capture Engine
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Create a `DemoCaptureEngine` that provides virtual displays and produces animated test pattern frames, allowing screen capture workflows to function in demo mode without real monitors.
## Tasks
- [ ] Task 1: Create `server/src/wled_controller/core/capture_engines/demo_engine.py` with `DemoCaptureEngine` and `DemoCaptureStream`
- [ ] Task 2: `DemoCaptureEngine.ENGINE_TYPE = "demo"`, `ENGINE_PRIORITY = 1000` (highest in demo mode)
- [ ] Task 3: `is_available()` returns `True` only when `is_demo_mode()` is True
- [ ] Task 4: `get_available_displays()` returns 3 virtual displays:
- "Demo Display 1080p" (1920×1080)
- "Demo Ultrawide" (3440×1440)
- "Demo Portrait" (1080×1920)
- [ ] Task 5: `DemoCaptureStream.capture_frame()` produces animated test patterns:
- Horizontally scrolling rainbow gradient (simple, visually clear)
- Uses `time.time()` for animation so frames change over time
- Returns proper `ScreenCapture` with RGB numpy array
- [ ] Task 6: Register `DemoCaptureEngine` in `capture_engines/__init__.py`
## Files to Modify/Create
- `server/src/wled_controller/core/capture_engines/demo_engine.py` — New file: DemoCaptureEngine + DemoCaptureStream
- `server/src/wled_controller/core/capture_engines/__init__.py` — Register DemoCaptureEngine
## Acceptance Criteria
- `DemoCaptureEngine.is_available()` is True only in demo mode
- Virtual displays appear in the display list API when in demo mode
- `capture_frame()` returns valid RGB frames that change over time
- Engine is properly registered in EngineRegistry
## Notes
- Test patterns should be computationally cheap (no heavy image processing)
- Use numpy operations for pattern generation (vectorized, fast)
- Frame dimensions must match the virtual display dimensions
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -1,47 +0,0 @@
# Phase 3: Virtual Audio Engine
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Create a `DemoAudioEngine` that provides virtual audio devices and produces synthetic audio data, enabling audio-reactive visualizations in demo mode.
## Tasks
- [ ] Task 1: Create `server/src/wled_controller/core/audio/demo_engine.py` with `DemoAudioEngine` and `DemoAudioCaptureStream`
- [ ] Task 2: `DemoAudioEngine.ENGINE_TYPE = "demo"`, `ENGINE_PRIORITY = 1000`
- [ ] Task 3: `is_available()` returns `True` only when `is_demo_mode()` is True
- [ ] Task 4: `enumerate_devices()` returns 2 virtual devices:
- "Demo Microphone" (input, not loopback)
- "Demo System Audio" (loopback)
- [ ] Task 5: `DemoAudioCaptureStream` implements:
- `channels = 2`, `sample_rate = 44100`, `chunk_size = 1024`
- `read_chunk()` produces synthetic audio: a mix of sine waves with slowly varying frequencies to simulate music-like beat patterns
- Returns proper float32 ndarray
- [ ] Task 6: Register `DemoAudioEngine` in `audio/__init__.py`
## Files to Modify/Create
- `server/src/wled_controller/core/audio/demo_engine.py` — New file: DemoAudioEngine + DemoAudioCaptureStream
- `server/src/wled_controller/core/audio/__init__.py` — Register DemoAudioEngine
## Acceptance Criteria
- `DemoAudioEngine.is_available()` is True only in demo mode
- Virtual audio devices appear in audio device enumeration when in demo mode
- `read_chunk()` returns valid float32 audio data that varies over time
- Audio analyzer produces non-trivial frequency band data from the synthetic signal
## Notes
- Synthetic audio should produce interesting FFT results (multiple frequencies, amplitude modulation)
- Keep it computationally lightweight
- Must conform to `AudioCaptureStreamBase` interface exactly
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -1,54 +0,0 @@
# Phase 4: Demo Device Provider & Seed Data
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Create a demo device provider that exposes discoverable virtual LED devices, and build a seed data generator that populates the demo data directory with sample entities on first run.
## Tasks
- [ ] Task 1: Create `server/src/wled_controller/core/devices/demo_provider.py``DemoDeviceProvider` extending `LEDDeviceProvider`:
- `device_type = "demo"`
- `capabilities = {"manual_led_count", "power_control", "brightness_control", "static_color"}`
- `create_client()` returns a `MockClient` (reuse existing)
- `discover()` returns 3 pre-defined virtual devices:
- "Demo LED Strip" (60 LEDs, ip="demo-strip")
- "Demo LED Matrix" (256 LEDs / 16×16, ip="demo-matrix")
- "Demo LED Ring" (24 LEDs, ip="demo-ring")
- `check_health()` always returns online with simulated ~2ms latency
- `validate_device()` returns `{"led_count": <from url>}`
- [ ] Task 2: Register `DemoDeviceProvider` in `led_client.py` `_register_builtin_providers()`
- [ ] Task 3: Create `server/src/wled_controller/core/demo_seed.py` — seed data generator:
- Function `seed_demo_data(storage_config: StorageConfig)` that checks if demo data dir is empty and populates it
- Seed entities: 3 devices (matching discover results), 2 output targets, 2 picture sources (using demo engine), 2 CSS sources (gradient + color_cycle), 1 audio source (using demo engine), 1 scene preset, 1 automation
- Use proper ID formats matching existing conventions (e.g., `dev_<hex>`, `tgt_<hex>`, etc.)
- [ ] Task 4: Call `seed_demo_data()` during server startup in `main.py` when demo mode is active (before stores are loaded)
## Files to Modify/Create
- `server/src/wled_controller/core/devices/demo_provider.py` — New: DemoDeviceProvider
- `server/src/wled_controller/core/devices/led_client.py` — Register DemoDeviceProvider
- `server/src/wled_controller/core/demo_seed.py` — New: seed data generator
- `server/src/wled_controller/main.py` — Call seed on demo startup
## Acceptance Criteria
- Demo devices appear in discovery results when in demo mode
- Seed data populates `data/demo/` with valid JSON files on first demo run
- Subsequent demo runs don't overwrite existing demo data
- All seeded entities load correctly in stores
## Notes
- Seed data must match the exact schema expected by each store (look at existing JSON files for format)
- Use the entity dataclass `to_dict()` / store patterns to generate valid data
- Demo discovery should NOT appear when not in demo mode
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -1,50 +0,0 @@
# Phase 5: Frontend Demo Indicator & Sandbox UX
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Add visual indicators in the frontend that clearly communicate demo mode status to the user, including a badge, dismissible banner, and engine labeling.
## Tasks
- [ ] Task 1: Add `demo_mode` field to system info API response schema (if not already done in Phase 1)
- [ ] Task 2: In frontend initialization (`app.ts` or `state.ts`), fetch system info and store `demoMode` in app state
- [ ] Task 3: Add `<span class="demo-badge" id="demo-badge" style="display:none">DEMO</span>` next to app title in `index.html` header
- [ ] Task 4: CSS for `.demo-badge`: amber/yellow pill shape, subtle pulse animation, clearly visible but not distracting
- [ ] Task 5: On app load, if `demoMode` is true: show badge, set `document.body.dataset.demo = 'true'`
- [ ] Task 6: Add a dismissible demo banner at the top of the page: "You're in demo mode — all devices and data are virtual. No real hardware is used." with a dismiss (×) button. Store dismissal in localStorage.
- [ ] Task 7: Add i18n keys for demo badge and banner text in `en.json`, `ru.json`, `zh.json`
- [ ] Task 8: In engine/display dropdowns, demo engines should display with "Demo: " prefix for clarity
## Files to Modify/Create
- `server/src/wled_controller/templates/index.html` — Demo badge + banner HTML
- `server/src/wled_controller/static/css/app.css` — Demo badge + banner styles
- `server/src/wled_controller/static/js/app.ts` — Demo mode detection and UI toggle
- `server/src/wled_controller/static/js/core/state.ts` — Store demo mode flag
- `server/src/wled_controller/static/locales/en.json` — i18n keys
- `server/src/wled_controller/static/locales/ru.json` — i18n keys
- `server/src/wled_controller/static/locales/zh.json` — i18n keys
## Acceptance Criteria
- Demo badge visible next to "LED Grab" title when in demo mode
- Demo badge hidden when not in demo mode
- Banner appears on first demo visit, can be dismissed, stays dismissed across refreshes
- Engine dropdowns clearly label demo engines
- All text is localized
## Notes
- Badge should use `--warning-color` or a custom amber for the pill
- Banner should be a thin strip, not intrusive
- `localStorage` key: `demo-banner-dismissed`
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->

Some files were not shown because too many files have changed in this diff Show More