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