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.
25 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.
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.
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