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.
29 KiB
LedGrab TODO
WebUI Redesign — "Lumenworks" Studio-Console Aesthetic
Full-app UI/UX refresh. Design direction committed to by user 2026-04-24. Mockup lives at server/docs/ui-redesign-mockup.html. Phases are independent and CSS-only where possible — backend untouched.
Phase 1 — Design tokens & font embed
- Embed variable fonts (
server/src/ledgrab/static/fonts/): Manrope (latin + latin-ext + cyrillic + cyrillic-ext), JetBrains Mono (same 4 subsets), Big Shoulders Display (latin + latin-ext). Total +201 KB gzipped, served viaunicode-rangeso only latin paints on first load. fonts.css— declare@font-faceentries for all new families with properunicode-rangesubsetting; keep DM Sans + Orbitron registered for legacy-token callers during migration.base.css— add additive Lumenworks tokens:--font-display/--font-brand/--font-body,--lux-r-*,--lux-hairline,--lux-rule. Both[data-theme="dark"]and[data-theme="light"]define--lux-bg-0…3,--lux-line/-bold,--lux-ink/-dim/-mute/-faint,--ch-signal/-cyan/-magenta/-amber/-coral/-violet,--lux-signal-glow,--lux-shadow-rack. Existing tokens untouched — no visual regression.
Phase 2 — Shell (header → transport bar + channel-strip sidebar)
index.html—.tab-barmoved out of<header>into a new<aside class="sidebar">; wrapped content in.app-body2-col grid (sidebar | main)..transport-centersection added between.header-titleand.header-toolbarwith a placeholder.transport-statuschip ("Ready" → "Armed · N live" wired in Phase 3). All tab-button IDs,data-tabattributes, andonclick="switchTab(…)"handlers preserved.layout.css—<header>rebuilt as the transport bar: 3-column grid (brand | center | toolbar), 60 px fixed height, sticky, gradient bottom rule with channel-color wash..header-title::before/::afterrender the glowing LED brand mark;#server-statusrepositioned as the LED core pip.#server-versionrestyled as a mono-type console badge.sidebar.css(new) — vertical channel-strip navigation. Active tab gets a glowing left stripe + radial tint..sidebar-footcontains a.cpu-meterplate with two live bars (Load, FPS) ready to be JS-bound in Phase 3. Collapses to a 56 px icon rail at ≤1100 px; hides entirely at ≤600 px viadisplay: contentsso.tab-barfalls through tomobile.css's fixed-bottom strip unchanged.all.css— new sidebar import after layout.base.css— body font-family switched tovar(--font-body)which resolves to Manrope (with DM Sans + system fallbacks). Addedfont-feature-settingsfor stylistic set + alternate 1.- Locale additions:
sidebar.workspaces,sidebar.load,sidebar.fps,transport.status.ready,transport.status.armedin en/ru/zh. - Tutorial + auth selectors (
header .header-title,#tab-btn-*,.tab-barquerySelector,a.header-link[href="/docs"], onclick markers on theme/settings/search) all survive the move. - JS: bind
.cpu-meter+.transport-statuschip to existingperformanceWebSocket / poller. Done as part of Phase 3. - Tablet-range visual polish pass once other phases render (some tabs currently have their own internal sticky headers that may overlap the transport bar on narrow viewports).
Phase 3 — Dashboard hero + module redesign
cards.css—.cardgets rack-module treatment: channel stripe on left edge (color-coded viadata-card-type+.ch-*utility classes),::aftercorner bracket in top-right, mono-typed metric labels planned for Phase 4. Running cards glow the stripe brighter + emit asignalFlowkeyframe strip along the bottom edge.- Removed the
@property --border-anglerotating conic-gradient border (retired the WebKit mask workaround + light-theme variant + fallback for@supports not (mask-composite: exclude)). Replaced with the signal-flow strip — one animated linear-gradient on a 2 px line, no GPU layer compositing per card. dashboard.css—.dashboard-targetrows pick up the same channel stripe + signal-flow treatment. Section headers now use mono caps with a channel-green underline accent. Metric values use mono with tabular numerics; labels use silkscreened micro-caps.- Skeleton-card rewritten: left hairline + corner bracket so it reads
as "loading module" instead of a generic flashing block.
skeletonShimmergradient replaces the old opacity-pulse on--text-color. _updateSidebarMeterbinds CPU% (Load) and app-CPU share (FPS) to the sidebar meter plate on every perf poll._updateTransportStatusupdates the transport chip ("Ready" → "Armed · N live") whenever the dashboard's running-target set is recomputed..hero4-cell readout row (Active Patches / Throughput / CPU / Latency + inline sparklines) — CSS tokens + layout are ready; HTML render deferred until the dashboard JS is refactored to emit it (Phase 3b, non-blocking).
Phase 4 — Other tabs adopt module language
tree-nav.css— trigger pill gets a channel stripe on its left edge (glows + widens when open). Trigger title uses mono-uppercase with wide letter-spacing. Dropdown panel has a gradient channel-accent rule across its top edge. Group headers use silkscreened micro-caps with a small square marker instead of the old bold-uppercase. Active leaf has a pulsing LED pip on the left and a channel tint behind it. Count badges switched to mono tabular-nums in 2-px-radius pills..subtab-section-header— channel-green underline accent + mono micro-caps. Consistent with the dashboard-section pattern so the whole app shares one section-header language..stream-tab-btnsub-tabs — mono uppercase with wide tracking, active tab shows channel-green underline + glowing count badge..perf-chart-card— channel stripe on the left (replaces oldborder-topaccent). Per-metric accents swapped to channel palette (--ch-coralfor CPU,--ch-violetfor RAM,--ch-signalfor GPU,--ch-amberfor temp). Corner bracket added. Metric values pick uptabular-nums+ a soft glow.cards.css— channel-color mapping extended to attributes the JS already emits (data-target-id→ green,data-stream-id→ cyan,data-audio-source-id→ magenta,data-automation-id/data-scene-id→ violet). No JS changes required; cards pick up their correct stripe automatically on the Targets/Sources/Automations tabs.- Graph editor — toolbar gets a gradient background + hairline + rack shadow + backdrop blur. Canvas and nodes untouched.
.template-card— Lumenworks treatment (channel stripe on left, corner bracket top-right, hairline border, hover lift + stripe glow). Brings Inputs (streams / capture / pp / cspt / pattern templates) and Integrations (HA / MQTT / weather / value / sync-clock / game-integration cards) up to the same visual language as.cardand.dashboard-target.cards.css— channel mapping extended to.template-card. Direct attr hooks fordata-stream-id/data-template-id/data-pp-template-id(cyan),data-cspt-id/data-pattern-template-id(signal),data-audio-template-id/data-apt-id(magenta). Section-scoped hooks via[data-card-section="…"]for cards that share a genericdata-id(HA / MQTT / weather / value → cyan; game-integrations → amber; sync-clocks → violet; HA-light-targets → signal). No JS changes — uses the section markupCardSectionalready emits.- Graph editor nodes — body fill
--lux-bg-1with hairline stroke, hover bold-line, selected/running stroke--ch-signalwith drop-shadow glow. Title font switched from DM Sans to--font-display; subtitle to mono uppercase wide-tracking. Port-drop-target glow recoloured to--ch-signal. Port labels adopt the mono caption treatment. Grid dots use--lux-line. Running gradient stops switched from--primary-color/--success-colorto channel palette (signal → cyan → signal).
Phase 5 — Modal restyle
modal.css— backdrop gains a radial dim + 6 px blur for stronger separation..modal-contentgets a gradient background + hairline + deep rack shadow. Channel-accent rule across the top edge driven by--modal-ch(per-modal override). Corner bracket bottom-right on desktop..modal-headergains a vertical channel-color stripe to the left of the title;.modal-footerpicks up a hairline divider.- Per-modal channel mapping by modal ID: - Target editors → green - Input/Source editors → cyan - Audio editors → magenta - Automation / Scene / Game editors → violet - Settings / API key / Setup / Notifications → amber - Confirm dialog → coral
components.css— inputs use hairline borders, tabular-nums mono forinput[type="number"], channel-green focus ring + glow. Buttons use mono-uppercase type, signal-glow on primary, coral-glow on danger.<select>audit deferred (project already enforces via CLAUDE.md rule + IconSelect/EntitySelect wrappers).
Phase 6 — Mobile dedicated shell
mobile.css(existing file, not forked) — fixed-bottom.tab-barpromoted to full Lumenworks treatment: gradient background + hairline divider at top + channel-accent rule matching the transport-bar bottom. Active tab gets an LED pip above the icon and a channel-tint background. Tab labels + badges use mono uppercase to match the rest of the app. Phone (≤600 px): modal corner-bracket hidden (fullscreen modals), modal-header stripe slimmed to 18 px.- Phase 2's layout.css already strips the transport-center on phones
and collapses the sidebar via
display: contents, so the mobile shell automatically routes the tab-bar to the bottom without a separate JS hook. - [WONTDO] Fork into
mobile-shell.css— keeping changes inmobile.csssince the cascade was already organized by viewport. A rename adds churn without improving maintainability.
Phase 7 — Microcopy + retire legacy
- Locale rename:
targets.title+dashboard.section.targets→ "Channels" (en) / "Каналы" (ru) / "通道" (zh);streams.title→ "Inputs" / "Входы" / "输入". Automations kept as-is (Automations + Scenes is a meaningful distinction; "Patches" would conflate them). Internal tab keys (dashboard/automations/targets/streams/integrations/graph) unchanged so no JS or localStorage migration needed. - Ambient WebGL background — default is already
off; kept the toggle button and localStorage preference so users who want the shader can turn it on. No entry-point change needed:data-bg-animis initialized from localStorage withofffallback. - [DEFERRED] Delete DM Sans + legacy color tokens — would cascade through
every file that reads
--primary-color/--text-coloretc. Safer as a separate cleanup PR after the new design has soaked. - [WONTDO] Delete
mobile.css— Phase 6 kept the filename.
Dashboard Customization
Per-account dashboard layout — slide-in Customize panel lets users
toggle section / perf-cell visibility, reorder via drag, change density,
pick presets, and import/export the layout as JSON. Server-synced via
db.get_setting('dashboard_layout') so settings follow the user.
js/features/dashboard-layout.ts— schema (open registry of section / perf-cell keys so v1.1 cards slot in with no migration), defaults, 5 built-in presets (Studio/Operator/Showrunner/Diagnostics/TV), localStorage cache + server sync, legacy-key migration fromdashboard_collapsed,perfMetricsMode,perfChartColor_*.api/routes/preferences.py—GET/PUT/DELETE /api/v1/preferences/dashboard-layout. Treats payload as opaque (frontend owns the schema); validates only that body is an object with a numericversion. 6 pytest tests intests/test_preferences_api.pycover round-trip, default-empty, validation, delete, and unknown-field passthrough.js/features/dashboard.ts— sections rendered into a fragment map, then assembled in layout-driven order; perf section stays pinned top (chart-persistence reasons) but its visibility is layout- driven. Layout-change subscription invalidates the in-place-update optimization so density / order / visibility changes always rebuild section HTML.js/features/perf-charts.ts—renderPerfSection()iteratesgetOrderedPerfCells(); existing legacysetPerfModewrites through to the layout so the global toggle and the customize panel stay in sync.js/features/dashboard-customize.ts+css/dashboard-customize.css— slide-in panel, hand-rolled HTML5 drag-and-drop reorder, ↑/↓ buttons for keyboard / TV remote, debounced (300 ms) autosave, live preview while open. Reset / export / import actions.- i18n keys for
dashboard.customize.*in en/ru/zh. - (v1.1) Audio meters section — peak / RMS / BPM bars per audio
source. Schema key
audio-metersalready reserved. - (v1.1) Alerts section — quiet by default, loud on issues.
Reserved key
alerts. - (v1.1) Live LED preview strip per running device. Reserved
key
led-preview. - (v1.1) Source thumbnails grid (1 fps multiviewer). Reserved
key
source-thumbs. - (v1.2) Pinned section (user-curated mix of targets / scenes /
devices). Reserved key
pinned. - (v1.2) Patch/flow map — read-only mini graph of routing.
Reserved key
flow.
BLE LED Controller Support (SP110E / Triones / Zengge / Govee)
Add support for Bluetooth Low Energy LED controllers driven by mobile apps like "LED Hue", HappyLighting, iLightsIn. Whole-strip ambient-color output only — these protocols don't support per-pixel streaming.
- Add
bleak>=0.22as optional extra[ble]inserver/pyproject.toml(desktop-only, NOT in androidbuild.gradle.kts) core/devices/ble_transport.py— bleak wrapper: scan, connect, write-with/without-responsecore/devices/ble_protocols/package__init__.py—BLEProtocoldataclass + registry (family → encoder)sp110e.py— SP110E / SP108E (service FFE0, char FFE1,RR GG BB 00 1Estatic-color frame)triones.py— Triones / HappyLighting / LEDnet (service FFE5, char FFE9,7E 07 05 03 RR GG BB 10 EF)zengge.py— Zengge / iLightsIn (service FFE0, framing56 RR GG BB 00 F0 AA)govee.py— Govee unencrypted framed protocol (AES keyed variants — marked experimental)
core/devices/ble_client.py— unifiedBLEClient(LEDClient)— picks protocol byble_family, averages strip → one color, drops duplicate frames, rate-limits to BLE connection intervalcore/devices/ble_provider.py—BLEDeviceProvider+ discovery viaBleakScanner- Register in
core/devices/led_client.py::_register_builtin_providers(guardedtry/except ImportError) - Storage:
ble_family,ble_govee_keyfields threaded throughDevice.__init__/to_dict/from_dict/_UPDATABLE_FIELDS/create_device - Schemas: BLE fields on
DeviceCreate,DeviceUpdate,DeviceResponse - Routes: BLE fields propagated through create/update in
api/routes/devices.py+_device_to_response - ProcessorManager:
ble_family/ble_govee_keyadded to_DEVICE_FIELD_DEFAULTSandDeviceInfo; passed throughwled_target_processor.pyandgroup_client.pytocreate_led_client - Tests: 21 protocol encoder unit tests + 16 BLEClient fake-transport tests — all passing, 814 total tests still green
- Frontend: BLE option in the device type picker with a bluetooth Lucide icon; add-device modal shows a 4-option
IconSelectfor protocol family (SP110E / Triones / Zengge / Govee) with a Govee-only AES key field that auto-hides for the other three families; URL label/placeholder/hint adapt toble://<address>pattern; submit payload carriesble_family(+ optionalble_govee_key); clone flow pre-fills family and key; modal dirty-check snapshots the new fields; network scan button now also discovers BLE peripherals via the existing/api/v1/devices/discover?device_type=bleendpoint - Frontend:
isBleDevicehelper incore/api.ts;ICON_BLUETOOTH+ICON_LIGHTBULBconstants incore/icons.ts;bluetoothpath incore/icon-paths.ts; i18n keys inen.json/ru.json/zh.json; TypeScript compiles; esbuild bundle rebuilt - Android BLE via Kotlin bridge —
BleBridge.ktsingleton (scan/connect/write/disconnect);android_ble_transport.pyPython wrapper;make_transport()factory inble_transport.pyauto-selects backend;BleBridge.init()called fromLedGrabApp.onCreate; BLE permissions inAndroidManifest.xml - Govee per-model AES key —
_encrypt_govee_frame()inble_client.pyuses AES-128-ECB fromcryptography; key validated onBLEClientconstruction; applied to bothsend_pixelsandset_power; 8 new AES unit tests
Android — Restore Multi-ABI Wheels
During emulator testing, we switched the build to x86 only (see android/app/build.gradle.kts abiFilters) to avoid having to keep the arm64-v8a / x86_64 pydantic-core wheels current. Before shipping, restore all three ABIs:
- Rebuild
pydantic-corewheels for all three ABIs with the current SOABI + libpython linking settings (android/build-scripts/build-pydantic-core.sh— now supportsarm64,x86_64,x86args; defaults to all three). - Verify wheels: all three now list
libpython3.11.soinNEEDED(llvm-readelf -d), automated in the build script. - Restored
abiFilters += listOf("arm64-v8a", "x86_64", "x86")inbuild.gradle.kts. Multi-ABI debug APK builds cleanly (~99 MB). - Re-test on real ARM64 Android TV hardware (still pending — only emulator-verified build).
Build cache + scripts live in android/build-scripts/ and android/.build-cache/ (junction host + sysconfigdata for each ABI).
Android CI Pipeline
Build the Android APK automatically on push/tag.
- Generate Gradle wrapper (
gradlew) and commit it - Create CI workflow (
.gitea/workflows/build-android.yml)- JDK 17 + Android SDK + NDK setup
- Python 3.11 for Chaquopy build
- Recreate the directory junction via
ln -son Linux CI ./gradlew assembleDebugon master push,assembleReleaseonv*tags (if signing secrets set)- Uploads APK as CI artifact; attaches to Gitea release on tag push
- Commit pre-built pydantic-core wheels to
android/wheels/(arm64, x86, x86_64) - APK signing for release builds — conditional signing config reads keystore from env vars (
ANDROID_KEYSTORE_PATH/_PASSWORD/_ALIAS/_KEY_PASSWORD), falls back to debug signing locally - Provision a real keystore and add the four CI secrets:
ANDROID_KEYSTORE_BASE64(base64-encoded .jks)ANDROID_KEYSTORE_PASSWORDANDROID_KEY_ALIASANDROID_KEY_PASSWORD
- Add
LedGrab-{tag}-android-release.apkrow to the release description table in.gitea/workflows/release.yml→create-releasejob - Verify the CI workflow passes end-to-end with the now-restored multi-ABI build (larger APK, longer Android build step)
Android Root Capture (No Permission Dialog, No System Indicator)
MediaProjection shows a mandatory system overlay/indicator while capturing — unavoidable on stock Android. Many cheap Android TV boxes ship pre-rooted, so an alternative root-only path gives much better UX.
- Root detection —
Root.ktchecks commonsubinary paths and, on demand, runssu -c idto actually prove UID 0. First call triggers Magisk's grant dialog; grant is cached per session. Exposed to Python via Chaquopy. RootScreenrecord.kt— spawnssu -c screenrecord --output-format=h264 --size=WxH -, feeds the H.264 stdout through a MediaCodec decoder whose output Surface is wired into an ImageReader (RGBA_8888, row-stride-aware). Decoded frames reach the Python pipeline viaPythonBridge.pushRootFrame.- Python-side
RootScreenrecordEngine(core/capture_engines/root_screenrecord_engine.py) mirrorsMediaProjectionEnginewithENGINE_PRIORITY=110(> MediaProjection's 100) so the factory picks it automatically when available. MainActivitytriesRoot.requestGrant()before launching the MediaProjection consent flow — on rooted devices the consent dialog is skipped entirely.CaptureServicehas acreateRootIntent()entry point that bypasses the MediaProjection path.- Fallback: if
Root.requestGrant()returns false (no root, user denied, orsutimeout) the existing MediaProjection flow runs unchanged. - Real-hardware test pending — need to verify on the user's Magisk'd TV box that: (1) grant dialog appears once, (2) frames actually flow through MediaCodec without the Android 14 capture indicator showing, (3) stop/start cycle terminates the
suprocess cleanly. - [WONTDO]
SurfaceControl.screenshot()via reflection — renamed/moved across API 28/29/30/33, hidden-API blocklist varies by release, even rooted apps hit it; days of maintenance for a marginal latency win over the screenrecord path. Not worth it. - [WONTDO]
adb screencapfallback — full-PNG-per-frame pipeline is slower than MediaProjection, no value as a last resort.
Known projects using the screenrecord approach for reference: scrcpy (over ADB), scrcpy-hidden-api, shizuku.
Android Autostart on Boot
Boot-time, zero-interaction startup so LedGrab always has display capture and control on rooted TV boxes.
- Manifest: declare
RECEIVE_BOOT_COMPLETED,REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,WAKE_LOCK; register.BootReceiverforBOOT_COMPLETED/LOCKED_BOOT_COMPLETED/MY_PACKAGE_REPLACED. BootReceiver.kt— gated byAutostartPrefs+Root.looksRooted(); dispatchesCaptureService.createRootIntent()viaContextCompat.startForegroundService. Unrooted devices are a no-op because MediaProjection consent cannot be bypassed silently.AutostartPrefs.kt— thin SharedPreferences wrapper, defaults to enabled. Shown as a CheckBox on the stopped panel; greyed out on unrooted devices.CaptureServicereturnsSTART_REDELIVER_INTENTfor root-mode intents so the OS can cleanly restart the service after being killed (token-free path). MediaProjection-mode keepsSTART_NOT_STICKY— restart is pointless with a dead consent token.isRunningrace: moved assignment to afterstartForegroundsucceeds, resets on exception;onStartCommandwrapsstartForegroundin try/catch and stops the service cleanly if the FG transition fails.- Root-capture watchdog: coroutine on
serviceScopechecksRootScreenrecord.framesDeliveredevery 5s after a 5s grace. Respawns the pipeline (reusing the existing Python bridge) on stall, caps at 3 consecutive restarts before giving up. RootScreenrecord.framesDeliveredexposed as a property backed byAtomicInteger(was@Volatile var framesDelivered = 0with non-atomic+= 1).ScreenCaptureacceptsonProjectionStoppedlambda —MediaProjection.Callback.onStopnow tears the whole service down instead of leaving a stale FG notification.MainActivitywires the autostart toggle toAutostartPrefs; enabling it promptsREQUEST_IGNORE_BATTERY_OPTIMIZATIONSso Doze doesn't kill the FG service on phones.versionCodederived fromgit rev-list --count HEAD(orANDROID_VERSION_CODEenv var in CI). Was stuck at 1 — sideload updates were silently refusing to install.- Real-hardware test pending — need to verify on the user's Magisk'd TV box that: (1) boot-time autostart dispatches the service without UI, (2) capture indicator still absent under root mode post-reboot, (3) watchdog respawns the pipeline when
screenrecordis externally killed, (4) sideload upgrade installs cleanly after the versionCode bump. - Optional follow-up: "kiosk" mode — add
<category android:name="android.intent.category.HOME" />toMainActivityso power users can set LedGrab as the default TV launcher for truly always-running behavior.
Android USB Serial Support
Drive USB LED controllers (APA102, WS2812) connected directly to the Android TV box via USB-to-serial adapters.
- Added
com.github.mik3y:usb-serial-for-android:3.8.1(via JitPack) toandroid/app/build.gradle.kts. - Kotlin
UsbSerialBridgesingleton (android/app/src/main/java/com/ledgrab/android/UsbSerialBridge.kt) — exposeslistDevices(),open(vid, pid, serial, baud),write(handle, ByteArray),close(handle). Permission request fires automatically fromopen()when the user hasn't granted access yet. Handles are opaque integers, port map is synchronized, so Python threads can share one bridge. - Python
AndroidSerialTransportinserver/src/ledgrab/core/devices/android_serial_transport.pydrives the bridge through Chaquopy.SerialTransportProtocol +PySerialTransport+list_serial_ports()factory live inserial_transport.py;AdalightClientandSerialDeviceProvidernow go through the abstraction instead of importingpyserialdirectly. - URL scheme extended:
usb:VID:PID[:serial][@baud]on Android alongside the existingCOM3[:baud]//dev/ttyUSB0[:baud]desktop paths. - App initializes the bridge on startup (
LedGrabApp.onCreate→UsbSerialBridge.init(this)); manifest declaresuses-feature android.hardware.usb.host. - Real-device test pending — no USB-serial hardware on dev machine. Need to verify on a TV box with CH340, CP2102, or FTDI adapter.
- Document supported USB LED controllers in README (once real-device test passes).
- Optional: auto-launch the app when a known USB-serial adapter is plugged in (intent-filter on
USB_DEVICE_ATTACHED+res/xml/device_filter.xml). Skipped in v1 — users can just open LedGrab and hit "Discover". - ESP-NOW client (
espnow_client.py/espnow_provider.py) now routes throughSerialTransport—open_transport()for the gateway serial link,list_serial_ports()+port_exists()for discovery/validation. Works transparently withusb:VID:PIDURLs on Android. (Gateway protocol is write-only, so noread()extension was needed after all.)
Performance Metrics Abstraction
MetricsProviderprotocol + dataclass DTOs (MemorySnapshot,ProcessSnapshot) live inserver/src/ledgrab/utils/metrics/types.py. Each provider has its own module:psutil_provider.py,null_provider.py,android_provider.py.- Factory
get_metrics_provider()inutils/metrics/__init__.pyselects Android → psutil → Null.psutilimport is now confined to one place. api/routes/system.pyandcore/processing/metrics_history.pyuse the provider; no moreif psutil is not Noneguards in the hot paths.- Android
/proc-backed provider implemented (/proc/stat,/proc/meminfo,/proc/self/stat,/proc/self/status). Carries previous-sample state for delta-based CPU%; degrades to zeros if any/procfile is locked down. 12 unit tests cover both desktop and Android paths.
Android Performance Metrics — Future Enhancements
Beyond the /proc-based AndroidMetricsProvider that's now in place:
- Device battery + thermal-zone readings (
/sys/class/power_supply/battery/{capacity,temp},/sys/class/thermal/thermal_zone*/tempfiltered by zone type). Surfaced throughMetricsProvider.thermals(),PerformanceResponse.{cpu_temp_c,battery_percent,battery_temp_c}, the metrics-history snapshot, and a new dashboard temperature chart that hides itself when the backend reports null. GPU card now hides (no "unavailable" placeholder) when no GPU is present. - [WONTDO] Optional: app-specific memory via
Debug.getMemoryInfo()through a Kotlin → Python Chaquopy bridge (more accurate thanVmRSSfor split-app-process accounting) - [WONTDO] Optional: GPU usage via
/sys/class/kgsl/kgsl-3d0/gpubusyon Adreno, Mali-specific paths for Mali GPUs
Refactor: Per-Provider Device Configs
Replace flat DeviceInfo + **kwargs provider contract with a discriminated union of typed per-provider config dataclasses. Full plan: docs/plans/device-typed-configs.md.
- Phase 1 —
DeviceConfighierarchy +Device.to_config()(non-breaking, additive only) - Phases 2+3 — narrow
LEDDeviceProvider.create_clientto typed configs; migrate 3 call sites; deleteDeviceInfo+_get_device_info+_DEVICE_FIELD_DEFAULTS(single PR) - Phase 4 — migrate
tests/test_group_device.pytoGroupConfig/ProviderDeps; remove legacyGroupLEDClientinit path; 47-test config suite with 100% coverage ondevice_config.py - Phase 5 (separate PR, optional) — Pydantic v2 discriminated union in
api/schemas/devices.py; scope frontend POST/PATCH payloads bydevice_type