diff --git a/HARDWARE.md b/HARDWARE.md index f797667..2db88b5 100644 --- a/HARDWARE.md +++ b/HARDWARE.md @@ -1,6 +1,6 @@ # Hardware verification guide -Everything in this repo is verified by 1,173 JVM tests that run without +Everything in this repo is verified by 1,275 JVM tests that run without hardware. Three things remain that can only be proven against a real board and phone. This guide is the exact procedure. @@ -80,8 +80,9 @@ Then on the phone: - **Logging**: start a log, wait ~30 s, stop, download — progress bar completes, session appears in history, CSV export shares a file. - **Settings**: rename the device; rescan to see the new name. -4. No board handy? Toggle **demo mode** on the scan screen — the entire app - runs against a synthesized board. +4. No board handy? The app itself needs hardware, but the JVM suite drives + the same device stack against `DemoBleTransport` (a test-only emulator): + `./gradlew :app:testDebugUnitTest`. ## Troubleshooting diff --git a/README.md b/README.md index 9fa6d9e..3a000c1 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,89 @@ # MetaWear Android SDK (Kotlin) -A coroutine/Flow-native Kotlin SDK for MbientLab MetaWear sensors, built for -Android. Structured **KMP-ready, Android-only**: the pure protocol/parsing -layer has zero Android dependencies and is unit-tested on a plain JVM, while -the transport and app layers are Android-specific. +A coroutine/Flow-native Kotlin implementation of the [MetaWear protocol](https://github.com/mbientlab/MetaWear-API) for Android. -## Module layout +This repository contains both the reusable SDK modules and the MetaWear Android app: + +- Use `:metawear-protocol` + `:metawear-core` when you need the scanner, device state machine, BLE transport, protocol router, and every sensor/module API. +- Add `:metawear-persistence` when you want Room-backed session storage and CSV export. +- Add `:metawear-firmware` only when your app needs over-the-air (Nordic DFU) firmware updates. +- Open `app/` for the full MetaWear app — see [The MetaWear App](#the-metawear-app). + +The SDK is structured **KMP-ready, Android-only**: the pure protocol/parsing layer has zero Android dependencies and is unit-tested on a plain JVM, while the transport, persistence, firmware, and app layers are Android-specific. + +## Table of Contents + +| Start here | Use it for | +|------------|------------| +| [Requirements](#requirements) | Toolchain and Android versions | +| [Supported boards](#supported-boards) | Which MetaMotion boards the SDK targets and how the model is detected | +| [What ships in this repository](#what-ships-in-this-repository) | The five Gradle modules and what each one is for | +| [The MetaWear App](#the-metawear-app) | The full Jetpack Compose app — what it does, running it, and how it's built | +| [Development workflow](#development-workflow) | Repository layout, common Gradle commands, toolchain notes | +| [Quick Start](#quick-start) | Adding the SDK, permissions, scanning, connecting, streaming, and sending simple commands | +| [Architecture](#architecture) | Understanding the scanner/device/protocol/transport layering | +| [Layer-by-layer breakdown](#layer-by-layer-breakdown) | Scanner, device state machine and key signatures, transport split, protocol router | +| [Sensor protocols](#sensor-protocols) | The capability interfaces every sensor implements, plus generic read/poll | +| [Supported sensors and modules](#supported-sensors-and-modules) | Finding the Kotlin type and configuration shape for each MetaWear module | +| [Logging](#logging) | On-device flash logging, typed downloads, polled loggers, anonymous logger recovery, and CSV export | +| [Firmware updates](#firmware-updates) | Over-the-air DFU — catalog checks, flashing, bootloader chaining | +| [Persistence (Room)](#persistence-room) | Saving downloaded sessions and reconstructing typed samples | +| [Data modes](#data-modes) | Streaming vs. logging, and the flash entry layout | +| [BLE packet format](#ble-packet-format) | Wire format, characteristics, module IDs | +| [Testing](#testing) | Running JVM unit tests, the mock transport, and the instrumented hardware suites | +| [Demo transport (test fixture)](#demo-transport-test-fixture) | The protocol-level MetaMotion S emulator that drives the app's JVM tests | +| [Design notes](#design-notes) | Kotlin/coroutines gotchas the codebase relies on | +| [What's not yet implemented](#whats-not-yet-implemented) | Known gaps | + +## Requirements + +| Requirement | Version | +|------------------------|-------------------------------------------------------------| +| Android (runtime) | 8.0+ (`minSdk 26`); Android 12+ (API 31) recommended for the new `BLUETOOTH_SCAN` / `BLUETOOTH_CONNECT` permissions | +| Android (compile) | `compileSdk 36`, `targetSdk 36` | +| JDK (build) | 21 (Android Studio's bundled JBR works) | +| Gradle / AGP | Gradle 9.6, Android Gradle Plugin 9.2.1 | +| Kotlin | 2.1.20 (pure-JVM module), AGP 9 built-in Kotlin (Android modules) | +| IDE | Android Studio | +| Hardware for the app / instrumented tests | An Android phone with Bluetooth LE and a MetaMotion board | + +Key library versions: kotlinx-coroutines 1.10.1, kotlinx-datetime 0.6.2, Room 2.8.4 (KSP 2.3.9), Nordic Kotlin BLE Library 1.3.1, Nordic Android DFU Library 2.9.0, Compose BOM 2025.06.01. + +--- + +## Supported boards + +This SDK targets MbientLab MetaMotion boards. Anything else (legacy MetaWear R / RG / RPro / C / CPro, MetaMotion C, MetaEnvironment, MetaTracker, MetaHealth) decodes as `BoardModel.Unknown` — connect / read may still work but module-level behaviour is not validated. + +| Board | Model number (`0x2A24`) | Hardware revisions (`0x2A27`) | Verification status | +|-------------------|:-----------------------:|:---------------------------------------|:-------------------------------------------------| +| MetaMotion R / RL | `5` | `r0.1`, `r0.2`, `r0.3`, `r0.4`, `r0.5` | BMI160 wire-vector tests (JVM) | +| MetaMotion S | `8` | `r0.1` | BMI270 — verified on hardware (Pixel 7 + MetaMotion S) | + +`BoardModel` decodes the Model Number characteristic into a typed value; `DeviceInformation.model` and `DeviceInformation.isHardwareRevisionSupported` cross-check the revision against the table above: + +```kotlin +val info = device.deviceInfo ?: return +println(info.model.displayName) // "MetaMotion R / RL" or "MetaMotion S" +println(info.model.supportedHardwareRevisions) // ["r0.1", ..., "r0.5"] or ["r0.1"] +println(info.model.hasMMS) // true on MetaMotion S (larger flash, flush-page before download) +if (!info.isHardwareRevisionSupported) { + // Either an unsupported board model or a revision not on file. +} +``` + +The validator is forgiving about formatting — `"r0.4"`, `"R0.4"`, and `"0.4"` all match. + +--- + +## What ships in this repository ``` metawear-android/ ├── metawear-protocol/ ← pure Kotlin/JVM. Packet builder, parser, value types, │ sensor interfaces + configs, BleTransport seam + mock, │ protocol router, MetaWearDevice, MetaWearScanner. -│ Fast JVM unit tests (Steps 1–3 of the build plan). +│ Fast JVM unit tests. ├── metawear-core/ ← Android library. Nordic-backed BleTransport │ (NordicBleTransport), unfiltered scan source │ (AndroidBleScanSource), AndroidMetaWear entry point, @@ -24,289 +95,1655 @@ metawear-android/ │ release-catalog client, bootloader interlock, │ Flow-based DFU progress, MetaWearDevice extensions. │ JVM unit tests (catalog/version/interlock logic). -└── app/ ← Jetpack Compose demo app: scan, live streaming with +└── app/ ← Jetpack Compose MetaWear app: scan, live streaming with ring-buffer decimation, on-device logging + download, - session history with CSV export, LED/haptic controls, - device settings, firmware updates, and a - hardware-free demo mode (protocol-level MetaMotion S - emulator). -``` - -`:metawear-protocol` is the foundation everything else builds on, written -test-first to lock wire-format correctness before any BLE code. The full -vertical slice — scan → connect → `startStream(accelerometer)` → -`Flow>` — runs end-to-end against `MockBleTransport` -on the JVM, and against real hardware via `:metawear-core`'s smoke suite. - -The repo carries **1273 JVM tests** across the four testable modules -(1041 protocol + 46 persistence + 66 firmware + 120 app), plus instrumented -suites that need a device. - -## What's in `:metawear-protocol` - -The pure-JVM protocol layer (`com.mbientlab.metawear`): - -- `protocol.Module`, `protocol.Packet`, `protocol.PacketParser` — module - opcodes, command packet builder, and notification parser. All multi-byte - payloads are little-endian. -- `protocol.{Sensor, Streamable, Loggable, Readable, Command, CommandSequence, - Pollable}` — the capability interfaces every sensor module implements. -- `model.*` — pure value types: `CartesianFloat`, `Quaternion`, `EulerAngles`, - `Timestamped`, `Frequency`, `ModuleInfo`, `BoardModel`, `BoardState`, - `AnonymousSignal`, `DataTable` (CSV export), and the `MetaWearException` - hierarchy. -- `sensor.*` — one namespace per board module: accelerometers (BMI160/BMI270 - plus the Bosch interrupt surface and BMI270 step/activity features), - gyroscopes, magnetometer, sensor fusion + calibration, barometer/altimeter, - ambient light, thermometer, humidity, LED, haptic, switch, iBeacon, debug, - settings, timers, events, macros, GPIO, serial passthrough, data processors, - and misc readables. -- `transport.{BleTransport, ScanResult, WriteType}` + `MockBleTransport` — the - platform-agnostic BLE seam and its scriptable JVM mock. -- `protocol.ProtocolRouter` — routes BLE notifications between the transport - and the sensor modules. -- `MetaWearDevice`, `DeviceState`, `MetaWearScanner` — connection state - machine, streaming, send/read/poll surface, and scanner. - -The transport *interface* lives here (it is pure JVM: `java.util.UUID`, -`ByteArray`, `Flow`) so the protocol router and device layer stay -JVM-testable against `MockBleTransport`. Only the Nordic-backed implementation -is Android-specific and belongs in `:metawear-core`. - -Peripherals are identified by their Android MAC address, so the transport seam -uses an opaque `String` identifier. - -All 22 board modules are covered, plus the full device-side logging -surface: `startLogging`/`stopLogging` (including polled readables via the -timer→event→logger chain and processor handles), `downloadLogs` with chunk -reassembly and watchdog, `clearLog`/`flushLogPage`, logger/processor query and -recovery, anonymous-signal reconstruction, `factoryReset`, board-state -capture/restore, and `DataTable` CSV export. Data-processor streaming uses a -per-id demux: one shared `(0x09, 0x03)` subscription fans packets out to -per-processor-id flows, so multiple processors stream simultaneously; the -flows complete cleanly on an intentional disconnect and fail with the -underlying error on an unexpected one. - -**1041 JVM tests** cover this module, including reference byte vectors from -the MetaWear C++ SDK's Python test suite. - -## What's in `:metawear-core` - -The Android-only transport layer (`com.mbientlab.metawear.core`), built on the -[Nordic Kotlin BLE Library](https://github.com/NordicSemiconductor/Kotlin-BLE-Library): - -- `NordicBleTransport` — per-peripheral connect/write/read/notify/RSSI. -- `AndroidBleScanSource` — the unfiltered BLE scan source. -- `AndroidMetaWear` — default `MetaWearScanner()` wiring. -- `androidTest/HardwareSupport`, `HardwareSmokeTest` + 10 per-module suites - (gyro BMI270, magnetometer, sensor fusion, switch, haptic, GPIO, settings, - logging round-trip, environment, one-shot reads); suites self-skip without - a board. - -Transport notes: -- `connect()` retries Android's transient status-133 (`GATT_ERROR`) failures, - discovers all services (so Device Information Service reads resolve by - characteristic UUID alone), requests MTU 247 (packed 3-sample streaming does - not fit the 23-byte default), and enables notifications on `326A9006-…`. -- GATT operations are serialized by the Nordic client's internal per-connection - mutex — Android allows only one outstanding GATT op. -- Scans run **without** a service-UUID filter: MetaWear boards don't reliably - advertise the custom service UUID, so `MetaWearScanner` matches the - "MetaWear" name prefix instead. -- The library declares Bluetooth permissions but never requests them; apps - must be granted `BLUETOOTH_SCAN` + `BLUETOOTH_CONNECT` (API 31+) or the - legacy `BLUETOOTH`/`ACCESS_FINE_LOCATION` pair (API ≤ 30) first. - -## What's in `:metawear-persistence` - -Room-backed storage for downloaded log sessions -(`com.mbientlab.metawear.persistence`): - -- `SessionRecord`, `SampleRecord` — Room `@Entity` types with a cascade - foreign key (deleting a session deletes its samples). -- `PersistenceStore` — suspend methods over a Room DAO. -- `PersistenceDatabase` — create one per app. -- `SessionSnapshot` — immutable session summary. -- `Persistable` codec objects (`CartesianFloatPersistable`, …) — one singleton - codec per supported sample type. -- `PersistenceException` — the store's error taxonomy. - -All six supported sample types persist through one flat `(f0…f3, accuracy)` -record layout: `Float`, `Boolean`, `CartesianFloat`, `CorrectedCartesianFloat`, -`Quaternion`, `EulerAngles`. `Instant`s persist as epoch milliseconds; -sessions are identified by store-assigned UUID strings and devices by their -Android MAC identifier. `PersistenceStore.exportTable` rebuilds a `DataTable` -for CSV export straight from the database. - -Room's annotation processing runs through KSP — the standalone-versioned -KSP ≥ 2.3 line, which works with AGP 9's built-in Kotlin. **42 JVM unit -tests** cover the store logic (run against an in-memory fake of the DAO -interface), and an instrumented suite re-checks what the fake can only -mirror — the generated SQL's sort orders and counts, the foreign-key cascade, -and the epoch-millis converter — against a real Room database on-device. - -## What's in `:metawear-firmware` - -Nordic-DFU firmware updates (`com.mbientlab.metawear.firmware`) on top of the -[Nordic Android DFU Library](https://github.com/NordicSemiconductor/Android-DFU-Library): - -- `FirmwareServer`, `FirmwareFetcher` (+ `HttpUrlConnectionFetcher`) — HTTP - client for MbientLab's firmware release server. -- `FirmwareCatalog` — parser for the release-catalog JSON (hand-rolled, like - the BoardState codec). -- `FirmwareBuild` — value type describing one firmware artifact. -- `FirmwareException` — sealed error taxonomy with stable messages. -- `BootloaderInterlock` — pure flash-plan decision table. -- `MetaWearVersion.kt` — dotted-numeric version compare. -- `DFUProgress`, `DfuSession` — a cold Flow over the Nordic DFU library's - broadcasts. -- `MetaBootProbe` — one-shot GATT bootloader-version read. -- `FirmwareUpdate.kt` — `checkForFirmwareUpdate` / `updateFirmware` / - `updateFirmwareToLatest` extensions on `MetaWearDevice`. - -Usage: connect, then collect `device.updateFirmwareToLatest(context)` — a cold -`Flow` that walks catalog fetch → download → `[0xFE, 0x02]` -bootloader handoff (`sendExpectingDisconnect`) → Nordic DFU, emitting progress -the whole way; cancelling the collector aborts the transfer. An outdated -bootloader automatically becomes a multi-stage flash (bootloader chain first), -reported through `currentPart`/`totalParts`. When the flow completes, call -`connect()` again — the board rebooted and local device state is stale. - -Android specifics: -- The DFU transfer runs inside an Android service. The library declares - `MetaWearDfuService` (a `DfuBaseService` subclass) in its manifest, and - manifest merging carries the registration into your app — nothing to add - unless you subclass it for a foreground notification target, in which case - also create Nordic's notification channel via - `DfuServiceInitiator.createDfuNotificationChannel(context)` first. -- Firmware updates need the same `BLUETOOTH_CONNECT` runtime permission as - the rest of the SDK. -- The catalog JSON is parsed by a minimal hand-rolled parser (android.org.json - is stubbed out on the JVM unit-test classpath), keeping all **66 firmware - tests** — version compare, catalog selection, server/mock-fetcher, - bootloader interlock — plain JVM unit tests. The `DfuSession` / - `MetaBootProbe` wrappers are hardware-only and carry no unit-test coverage. - -## What's in `:app` - -A Jetpack Compose demo app, lean but feature-complete: - -- **Scan** — runtime BLE permission flow (`BLUETOOTH_SCAN`/`CONNECT` on 31+, - fine location on 26–30), nearby devices from the SDK scanner's StateFlows - (name, RSSI), remembered devices persisted by MAC. -- **Device hub** — connection state badge, model/firmware/battery summary, - identify (LED flash), reconnect/disconnect, feature navigation. -- **Live stream** — multi-sensor picker with a "Motion & Fusion" section - (accelerometer, gyroscope, magnetometer, and all seven sensor-fusion - outputs, with ODR/range chips and a 100 Hz BLE bandwidth advisor) and an - "Environmental" section: polled readables (temperature, humidity, polled - pressure — 1 s–5 m interval picker) plus streamed barometer pressure, - altitude, and ambient light with nominal-rate chips. Streamed sensors get a - dependency-free Canvas line chart, live readout, and true effective-Hz; - polled sensors get a latest-value tile (fed by `device.poll` one-shot - reads) plus the same chart; the quaternion output additionally renders a - live 3D orientation cube (a dependency-free Canvas wireframe with - orthographic projection and depth cueing) that is **tared**: it shows - rotation since a reference pose (auto-set from the first valid sample, - re-zeroed by the Zero button) through the IMU's 90° mounting correction — - the raw quaternion's absolute frame isn't stable session-to-session. While - a fusion output streams, a calibration badge polls the accuracy state every - 2 s and coaches per sensor; the bar is MEDIUM, since HIGH is a live score - the magnetometer legitimately loses indoors. - The charting hot path keeps sensor-rate work off the UI: samples ingest - into plain ring buffers on a background coroutine (full-resolution capture - + 1-in-N decimated display ring) and a ~33 ms ticker snapshots into Compose - state — nothing touches UI state at sensor rate. Stop archives each channel - to session history; buffers export as CSV (quaternion buffers gain derived - `heading,pitch,roll` columns matching the firmware's Euler convention). -- **Logging** — start/stop multi-sensor flash logging, elapsed clock, then a - single raw download drain with progress, per-sensor typed decode, and - persistence via `PersistenceStore`. Polled environmental sensors log - through the SDK's timer → event → logger chain (`PolledLogger`); streamed - barometer/ambient-light sessions decode to `Float`/lux samples — all export - through the same CSV path. Pending session records (including the - board-allocated polled-logger handles) are persisted, so they survive - process death: on the next download the SDK's `recoverLoggers` rebuilds the - chunk registry from the board's trigger table before decoding. -- **Group logging** — record the same sensors across a fleet of boards under - one shared group id: boards are armed sequentially (connect → clear → start - → verify entries actually land → disconnect), blink a gentle red recording - heartbeat that re-arms itself via on-board disconnect events (event ids - persisted for cleanup across app restarts), then stop + download the whole - batch with per-board progress. A board carrying a **foreign log** (someone - else's session) is detected on connect via a pure decision table — surfaced - for download/discard when decodable, cleared silently when it's undecodable - garbage, left alone when it's this app's own pending session. Foreign - downloads rebuild decoders from the board's own logger configuration - (anonymous signals) and label recovered sessions by signal identity. -- **Sessions** — history grouped by board identity (serial-keyed; titles - prefer the freshest stamped name, then the MAC; shared names get - disambiguated), swipe-to-delete with optimistic store delete, per-sample- - type chart styles recovered from the stored kind + capture-time label, - a 3D quaternion **replay** with play/pause/scrub timeline and 1×/2×/4× - speeds, and `epoch,elapsed_ms,…` CSV export (quaternion sessions include - derived Euler columns; filenames carry the capture-time board name and a - session-id discriminator) shared through the system sheet. -- **Controls** — LED color/pattern presets with play/stop, haptic motor - strength/pulse-width sliders, buzzer pulse. -- **Settings** — validated advertising rename, advertising interval/timeout, - TX power, a **Maintenance** section (LED reset, macro clear, event + timer - clear, restart-without-erase — each scoped so users don't reach for factory - reset), and a confirm-dialog factory reset. -- **Firmware** — catalog update check plus a Nordic-DFU update flow with - state/progress UI. -- **Demo mode** — `DemoBleTransport`, a protocol-level MetaMotion S emulator: - module discovery, device-info / battery / MAC / temperature / humidity / - pressure / illuminance / log reads, synthetic waveforms on every sensor - including packed registers, fusion outputs, altitude, and ambient light, - and full logging round trips — streamed, the polled timer/event chain, and - recovery across a simulated process restart. The identity fleet (up to 16 - distinguishable boards) backs a three-board simulated fleet in the app, so - group logging works end-to-end with no hardware. The scan screen offers - demo mode via a toggle (and suggests it when Bluetooth is off). The demo - pipeline — including full group-capture walks against the real persistence - store — is exercised end-to-end by JVM unit tests through the real - `MetaWearDevice`. - -The 3D orientation view is deliberately a Canvas wireframe cube: the -photoreal case model, button/LED window materials, and scene lighting of a -full 3D asset pipeline have no dependency-free equivalent, and only the -quaternion frame math is load-bearing. - -Install on a device or emulator: + group (fleet) logging, session history with CSV export, + LED/haptic controls, device settings, and firmware + updates. Its JVM tests drive the real device stack + against DemoBleTransport, a protocol-level + MetaMotion S emulator (test source set only). +``` + +The modules are intentionally split so an app can take just protocol + core without pulling in Room or the DFU library. `:metawear-protocol` is the foundation everything else builds on, written test-first to lock wire-format correctness before any BLE code. The full vertical slice — scan → connect → `startStream(accelerometer)` → `Flow>` — runs end-to-end against `MockBleTransport` on the JVM, and against real hardware via `:metawear-core`'s instrumented suites. + +The repo carries **1275 JVM tests** across the four testable modules (1043 protocol + 46 persistence + 66 firmware + 120 app), plus **43 BLE + 13 persistence instrumented tests** that need a phone (both hardware-verified on a Pixel 7 + MetaMotion S). + +--- + +## The MetaWear App + +`app/` is the MetaWear app — a Jetpack Compose app built on the four SDK modules (`:metawear-protocol` + `:metawear-core` for BLE + protocol, `:metawear-persistence` for storage and CSV, `:metawear-firmware` for DFU). It doubles as the reference consumer of the SDK: the public APIs an app needs are exercised here end to end. + +### What you can do + +| Area | What it does | +|------|--------------| +| **Scan & connect** | Runtime BLE permission flow, nearby MetaMotion boards with live name + RSSI from the scanner's `StateFlow`s, remembered devices persisted by MAC, reconnect | +| **Device hub** | Connection state badge, model / firmware / battery summary, identify (LED flash), reconnect / disconnect, feature navigation | +| **Live stream** | Multi-sensor picker in three collapsible groups — Motion (accelerometer, gyroscope, magnetometer with ODR/range chips), Fusion (all seven sensor-fusion outputs), and Environmental (polled temperature / humidity / pressure with a 1 s–5 min interval picker, plus streamed barometer pressure, altitude, and ambient light) — each group opening only when one of its sensors is selected, with a 100 Hz BLE bandwidth advisor. Canvas line charts, live readouts, true effective-Hz, a tared 3D orientation cube on the quaternion output, and a fusion calibration badge | +| **Logging & download** | Start / stop multi-sensor flash logging with an elapsed clock — the board keeps recording while disconnected — then reconnect and download with live progress (entry count and percent), per-sensor typed decode, and persistence, all shown above the sensor picker. The screen reconciles with the board on entry: a session the app didn't start (an earlier run, another app) is surfaced as "Logging" with a Stop action that keeps the recorded entries, and stale local records for cleared data are dropped. Pending sessions survive process death (`recoverLoggers` rebuilds the chunk registry) | +| **Group logging** | Record the same sensors across a fleet of boards under one shared group id, with a red recording heartbeat that re-arms itself via on-board disconnect events, then stop + download the whole batch with per-board progress. Foreign logs (another app's session) are detected on connect and offered for download via anonymous signals | +| **Session history** | Browse saved sessions grouped by board, re-plot them, replay quaternion sessions in 3D with a scrub timeline (1×/2×/4×), swipe-to-delete, and export any session to CSV through the system share sheet | +| **Controls** | LED color / pattern presets with play / stop, haptic motor strength and pulse-width sliders, buzzer pulse | +| **Settings** | Validated advertising rename, firmware update, a Logging section (entries in flash, every logger armed on the board with its module / register / byte range, refresh, and a confirmed Clear Logs & Loggers), advertising interval / timeout, TX power, a Maintenance section (reset LED, clear macros, clear events + timers, restart-without-erase), and a confirm-dialog factory reset | +| **Firmware** | Catalog update check plus a Nordic-DFU update flow with state / progress UI | + +### Running it + +1. Open the repo in Android Studio (JDK 21). The launch configuration is the **app** module. +2. Run on a physical Android phone (Android 8.0+, Android 12+ recommended) and connect a MetaMotion board over Bluetooth. Grant the Bluetooth permission prompts on first scan. The app needs real hardware — with Bluetooth off, the scan screen shows a plain "turn it on to scan" note; the hardware-free path is the JVM test suite (see [Demo transport (test fixture)](#demo-transport-test-fixture)). + +Or install from the command line: ```bash JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" \ ./gradlew :app:installDebug ``` -## Build & test +### How it's built + +- **Jetpack Compose + Material 3**, single-activity (`MainActivity`) with Navigation Compose string routes (`"scan"`, `"group"`, `"device"`, `"info"`, `"stream"`, `"logging"`, `"sessions"`, `"controls"`, `"settings"`, `"firmware"`). No DI framework — `AppContainer` (owned by `MetaWearApplication`) is the process-wide root state: the shared `MetaWearScanner`, `PersistenceStore`, remembered-device store, log-session registry, and the `GroupCaptureCoordinator`. Each screen is driven by a focused ViewModel in `vm/`. +- **High-rate streaming pipeline** — samples ingest into plain ring buffers on a background coroutine (a 600-sample full-resolution capture ring plus a 180-sample 1-in-N decimated display ring, `Channel.kt` / `RingBuffer.kt`), and a ~33 ms ticker snapshots into Compose state (`StreamSessionViewModel`), so nothing touches UI state at sensor rate and charts stay smooth at 200 Hz. +- **3D orientation** — the quaternion output renders a dependency-free Canvas wireframe cube (`QuaternionCube`, `TaredQuaternionCube`) that is **tared**: it shows rotation since a reference pose (auto-set from the first valid sample, re-zeroed by the Zero button) through the IMU's 90° mounting correction, because the raw quaternion's absolute frame isn't stable session-to-session. While a fusion output streams, a calibration badge polls `SensorFusionCalibrationState` every 2 s; the bar is MEDIUM, since HIGH is a live score the magnetometer legitimately loses indoors. +- **Logging** — polled environmental sensors log through the SDK's timer → event → logger chain (`PolledLogger`); pending session records including the board-allocated polled-logger handles are persisted, so a fresh process can `recoverLoggers` and finish the download. Group capture arms boards sequentially (connect → clear → start → verify entries land → disconnect) and detects foreign logs with a pure decision table (`ForeignLog.kt`). +- **CSV export** — live buffers export as `time,…` (quaternion buffers gain derived `heading,pitch,roll` matching the firmware's Euler convention); persisted sessions export as `epoch,elapsed_ms,…` via `PersistenceStore.exportTable`. Filenames carry the capture-time board name and a session-id discriminator; sharing goes through a `FileProvider`. + +### Where the code lives + +| Path (`app/src/main/kotlin/com/mbientlab/metawear/app/`) | Contents | +|------|----------| +| `MetaWearApplication.kt`, `MainActivity.kt`, `AppContainer.kt` | Entry point, single activity, root app state | +| `core/` | Pure-Kotlin logic: `RingBuffer`, `EffectiveHz`, `BandwidthAdvisor`, `CalibrationReadiness`, `QuaternionCube`, `QuaternionFrame`, `ReplayTimeline`, `SensorSelection`, `SessionHistoryGrouping` | +| `data/` | Device-facing adapters: `ConfiguredSensor` (+ `openStream` / `startLoggingOn` / `decodeAndSave`), `LogDownloader`, `LogSessionRegistry`, `ForeignLog.kt`, `PolledPressure`, `RecordingHeartbeat`, `RememberedDeviceStore` | +| `export/` | `LiveBufferCsvExporter`, `ExportFilename`, `CsvShare` | +| `vm/` | ViewModels (`ScannerViewModel`, `DeviceViewModel`, `StreamSessionViewModel`, `LogSessionViewModel`, `DownloadViewModel`, `SessionHistoryViewModel`, `ControlsViewModel`, `SettingsViewModel`, `FirmwareUpdateViewModel`), `Channel`, `GroupCaptureCoordinator` | +| `ui/` | `AppNavHost`, one folder per screen (`scan`, `device`, `stream`, `logging`, `sessions`, `controls`, `settings`, `firmware`), shared `components/` (`LineChart`, `TaredQuaternionCube`, `FusionCalibrationBadge`, glass cards / badges), `theme/` | + +`app/src/test` holds 120 JVM tests, including end-to-end walks through the real `MetaWearDevice` against `DemoBleTransport` (a test-only MetaMotion S emulator under `app/src/test/kotlin/com/mbientlab/metawear/app/demo/`) and full group-capture runs against the real persistence store. + +--- + +## Development workflow + +### Repository layout + +| Path | Purpose | +|------|---------| +| `metawear-protocol/src/main/kotlin/com/mbientlab/metawear/` | `MetaWearDevice`, `DeviceLogging.kt`, `DeviceAnonymousSignals.kt`, `MetaWearScanner`; `protocol/` (module opcodes, packet builder/parser, capability interfaces, `ProtocolRouter`, `PolledLogger`); `model/` (value types, `BoardModel`, `BoardState`, `AnonymousSignal`, `DataTable`, exceptions); `sensor/` (one file per board module); `transport/` (`BleTransport`, `MockBleTransport`, `Uuids`) | +| `metawear-protocol/src/test` | JVM unit tests for parsing, commands, modules, device state, logging, and mock-transport behaviour | +| `metawear-core/src/main` | `NordicBleTransport`, `AndroidBleScanSource`, `AndroidMetaWear` | +| `metawear-core/src/androidTest` | Instrumented hardware suites (`HardwareSmokeTest` + 10 per-module suites, `HardwareSupport`) | +| `metawear-persistence/src/main` | Room entities, DAO, `PersistenceDatabase`, `PersistenceStore`, `Persistable` codecs | +| `metawear-persistence/src/test`, `src/androidTest` | JVM store tests (fake DAO) and instrumented real-database tests | +| `metawear-firmware/src/main` | `FirmwareServer`, `FirmwareCatalog`, `FirmwareBuild`, `BootloaderInterlock`, `DfuSession`, `MetaBootProbe`, `FirmwareUpdate.kt`, `MetaWearDfuService` | +| `metawear-firmware/src/test` | Catalog, version, server, interlock tests | +| `app/` | The Compose app (see above) | +| `HARDWARE.md` | Step-by-step hardware verification guide (phone setup, running the instrumented suites, manual app pass) | +| `.github/workflows/ci.yml` | CI: JDK 21, all JVM test tasks, `assembleDebug` for every module | + +### Common commands ```bash -./gradlew :metawear-protocol:test # parsing/command/transport tests (JVM) +./gradlew :metawear-protocol:test # parsing/command/transport/device tests (JVM) ./gradlew :metawear-core:assembleDebug # Android transport library (AAR) ./gradlew :metawear-persistence:testDebugUnitTest # persistence tests (JVM) -./gradlew :metawear-persistence:connectedAndroidTest # Room round-trips (needs a device) +./gradlew :metawear-persistence:connectedAndroidTest # Room round-trips (needs a phone) ./gradlew :metawear-firmware:testDebugUnitTest # firmware tests (JVM) -./gradlew :app:testDebugUnitTest # app logic + demo-emulator tests (JVM) +./gradlew :app:testDebugUnitTest # app logic + demo-transport tests (JVM) ./gradlew :app:assembleDebug # Compose app APK +./gradlew :metawear-core:connectedAndroidTest # hardware suites (needs a phone + board) + +# Run one suite while developing a module. +./gradlew :metawear-protocol:test --tests '*LedTest*' +``` + +Opens directly in Android Studio; the Kotlin toolchain targets JDK 21. Set `JAVA_HOME` to a JDK 21 (Android Studio's bundled JBR: `/Applications/Android Studio.app/Contents/jbr/Contents/Home`). + +### Toolchain notes + +- `:metawear-core`, `:metawear-persistence`, `:metawear-firmware`, and `:app` use **AGP 9's built-in Kotlin** (no `org.jetbrains.kotlin.android` plugin — AGP 8.x does not run on this repo's Gradle 9.6). `:metawear-protocol` is a plain `kotlin("jvm")` module on Kotlin 2.1.20. +- `:app` adds the Compose compiler Gradle plugin (`org.jetbrains.kotlin.plugin.compose`) pinned to **2.2.10 — AGP 9.2.1's embedded Kotlin compiler version**, not the 2.1.20 used by the pure-JVM module's explicit Kotlin plugin. +- Room's annotation processing runs through **KSP** — the standalone-versioned KSP ≥ 2.3 line (2.3.9), which works with AGP 9's built-in Kotlin. +- Unit tests are JUnit 5 (Jupiter) with `kotlinx-coroutines-test` and Turbine; instrumented tests are JUnit 4 on `AndroidJUnitRunner`. + +### Documentation standards + +Public SDK types carry KDoc (`/** … */`) because they surface in Android Studio quick documentation and generated symbol docs. Implementation comments explain protocol quirks, firmware ordering constraints, or concurrency reasoning; avoid comments that merely restate a line of Kotlin. Markdown docs prefer small, runnable snippets and call out whether hardware is required. + +--- + +## Quick Start + +### Add the SDK to your build + +The SDK is **not yet published to Maven Central**. Until it is, consume it from source in one of two ways. + +**Option A — include the modules in your build.** Clone (or add as a git submodule) next to your project and include the modules you need in `settings.gradle.kts`: + +```kotlin +// settings.gradle.kts +include(":metawear-protocol", ":metawear-core", ":metawear-persistence", ":metawear-firmware") +project(":metawear-protocol").projectDir = file("../MetaWear-API-Kotlin/metawear-protocol") +project(":metawear-core").projectDir = file("../MetaWear-API-Kotlin/metawear-core") +project(":metawear-persistence").projectDir = file("../MetaWear-API-Kotlin/metawear-persistence") +project(":metawear-firmware").projectDir = file("../MetaWear-API-Kotlin/metawear-firmware") +``` + +```kotlin +// app/build.gradle.kts +dependencies { + implementation(project(":metawear-protocol")) + implementation(project(":metawear-core")) + implementation(project(":metawear-persistence")) // optional + implementation(project(":metawear-firmware")) // optional +} +``` + +**Option B — composite build.** Keep the SDK as an included build and substitute the planned coordinates: + +```kotlin +// settings.gradle.kts +includeBuild("../MetaWear-API-Kotlin") { + dependencySubstitution { + substitute(module("com.mbientlab:metawear-protocol")).using(project(":metawear-protocol")) + substitute(module("com.mbientlab:metawear-core")).using(project(":metawear-core")) + substitute(module("com.mbientlab:metawear-persistence")).using(project(":metawear-persistence")) + substitute(module("com.mbientlab:metawear-firmware")).using(project(":metawear-firmware")) + } +} +``` + +```kotlin +// app/build.gradle.kts — planned Maven coordinates (not yet published; version TBD) +dependencies { + implementation("com.mbientlab:metawear-protocol:") + implementation("com.mbientlab:metawear-core:") + implementation("com.mbientlab:metawear-persistence:") // optional + implementation("com.mbientlab:metawear-firmware:") // optional +} +``` + +The consuming project needs JDK 21, AGP 9.x, and `minSdk >= 26`. + +### Permissions + +The library declares the Bluetooth permissions in its manifest (merged into yours) but never requests them. Before calling `startScan()` or `connect()`, your app must have been **granted**: + +- **API 31+**: `BLUETOOTH_SCAN` and `BLUETOOTH_CONNECT` +- **API 26–30**: `BLUETOOTH` / `BLUETOOTH_ADMIN` and `ACCESS_FINE_LOCATION` + +```kotlin +val required = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) +} else { + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) +} +// request via ActivityResultContracts.RequestMultiplePermissions() +``` + +Scanning with the permissions missing fails the scan flow; connecting without `BLUETOOTH_CONNECT` throws a `SecurityException` from the Android stack. + +### Scan and connect + +```kotlin +import com.mbientlab.metawear.core.AndroidMetaWear + +// One scanner per app — owns the shared scan source and vends one MetaWearDevice per board. +val scanner = AndroidMetaWear.scanner(applicationContext) +scanner.startScan() + +// Drive a Compose list from scanner.discoveredDevices (StateFlow>). +delay(5.seconds) +scanner.stopScan() + +val device = scanner.discoveredDevices.value.values.firstOrNull() ?: return +device.connect() // suspends until Idle: device info + module discovery done +println(device.deviceInfo?.model?.displayName) +``` + +### Stream the accelerometer + +```kotlin +import com.mbientlab.metawear.sensor.AccelerometerBmi160 + +val sensor = AccelerometerBmi160(AccelerometerBmi160.Odr.HZ100, AccelerometerBmi160.Range.G2) +val stream: Flow> = device.startStream(sensor) + +val job = launch { + stream.collect { sample -> + println("${sample.time} ${sample.value.x} ${sample.value.y} ${sample.value.z}") + } +} +delay(5.seconds) +device.stopStreaming(sensor) +job.cancel() +``` + +On a MetaMotion S use `AccelerometerBmi270(...)` — or let the SDK pick from the module table with `Accelerometer.make(...)` (see [Auto-select accelerometer](#auto-select-accelerometer)). + +### Control the LED + +```kotlin +import com.mbientlab.metawear.sensor.Led +import com.mbientlab.metawear.sensor.LedPattern + +// Set a pattern then play it +device.send(Led.SetPattern(Led.Color.GREEN, LedPattern.breathe)) +device.send(Led.Play()) + +// Stop and clear after 3 seconds +delay(3.seconds) +device.send(Led.Stop()) +``` + +### Buzz the haptic motor + +```kotlin +import com.mbientlab.metawear.sensor.Haptic + +device.send(Haptic.motor(dutyCycle = 80, pulseWidth = 500)) +``` + +--- + +## Architecture + +``` +┌──────────────────────────────────────────────────────┐ +│ Your App / Compose UI │ +│ (ViewModels collecting StateFlow / Flow) │ +├──────────────────────────────────────────────────────┤ +│ MetaWearScanner (StateFlow maps) │ +│ MetaWearDevice (StateFlow, Mutex) │ +├──────────────────────────────────────────────────────┤ +│ Module layer (sensor/*) │ +│ AccelerometerBmi160/270, Gyroscope…, Led, Timer, │ +│ Event, Macro, Gpio, DataProcessor, Settings, … │ +├──────────────────────────────────────────────────────┤ +│ ProtocolRouter (internal, per device) │ +│ Packet / PacketParser (static helpers) │ +├──────────────────────────────────────────────────────┤ +│ BleTransport (interface, pure JVM) │ +├────────────────────────┬─────────────────────────────┤ +│ AndroidBleScanSource │ NordicBleTransport │ +│ (shared scan) │ (per device, Nordic BLE) │ +├────────────────────────┴─────────────────────────────┤ +│ MockBleTransport (JVM unit tests) │ +│ DemoBleTransport (app JVM tests, test-only) │ +│ connectedAndroidTest (hardware suites) │ +└──────────────────────────────────────────────────────┘ +``` + +`:metawear-protocol` holds everything above the platform line — including the `BleTransport` *interface* (it is pure JVM: `java.util.UUID`, `ByteArray`, `Flow`) — so the protocol router and device layer stay JVM-testable against `MockBleTransport`. Only the Nordic-backed implementation is Android-specific and belongs in `:metawear-core`. Peripherals are identified by their Android MAC address, so the transport seam uses an opaque `String` identifier. + +### Key design decisions + +| Concern | Choice | Why | +|---------------------|-----------------------------------------------------------|----------------------------------------------------------------------------------------------| +| Async streams | `kotlinx.coroutines.Flow` (cold) + `StateFlow` for state | Errors propagate on BLE drop; cancellation stops the stream; Compose collects natively | +| Thread safety | `MutableStateFlow.compareAndSet` for connection transitions + one `Mutex` (`opMutex`) serializing stream/log start-stop | Two sensors' command sequences can never interleave on the wire | +| BLE wrapper | Nordic Kotlin BLE Library behind the `BleTransport` seam | Coroutine-native GATT with per-connection serialization; the seam keeps the SDK testable | +| Scan admission | Unfiltered scan + `isMetaWearAdvertisement` (name prefix or service UUID) | Boards don't reliably advertise the service UUID; renamed boards still admitted | +| Time | `kotlinx.datetime.Instant` | Pure-JVM, no `java.time` dependency in the protocol module | +| Serialization | Hand-rolled JSON (`BoardState`, firmware catalog) | No serialization plugin or Android JSON dependency in JVM-tested code | +| Hardware tests | Instrumented (`connectedAndroidTest`) that **self-skip** without a board | Same task is safe on a bench without hardware | + +--- + +## Layer-by-layer breakdown + +### MetaWearScanner + +Exposes discovery state as `StateFlow` maps for Compose / ViewModel consumption. One scanner per app. The scan itself runs on the shared `AndroidBleScanSource`; each discovered peripheral gets its own `NordicBleTransport` and `MetaWearDevice` from the device factory wired by `AndroidMetaWear.scanner(context)`. + +```kotlin +class MetaWearScanner( + scanTransport: BleTransport, + scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + deviceFactory: (identifier: String) -> MetaWearDevice, +) { + val discoveredDevices: StateFlow> // MAC → device, admitted boards only + val advertisedNames: StateFlow> // most-recent local name per MAC + val advertisementRssi: StateFlow> // most-recent RSSI (dBm) per MAC + val isScanning: StateFlow + + fun startScan() + fun stopScan() + fun noteAdvertisedName(identifier: String, name: String) // seed the cache after a rename + fun clearAdvertisedName(identifier: String) // force next scan to recapture + fun deviceForKnownIdentifier(identifier: String): MetaWearDevice // remembered device, no re-discovery + + companion object { + fun isMetaWearAdvertisement(name: String, serviceUUIDs: List): Boolean + } +} +``` + +- Scans run **without** a service-UUID filter: MetaWear boards don't reliably include the custom service UUID in advertisements. Admission goes through `isMetaWearAdvertisement`, which accepts either the default `"MetaWear"` local-name prefix **or** the MetaWear service UUID when the packet carries it — so a board renamed to `"bob"` via `Settings.SetDeviceName` is still admitted. MetaBoot-mode boards (name `"MetaBoot"`, Nordic DFU service) match neither and stay excluded on purpose — the normal connect flow can't talk to a bootloader. +- `advertisedNames` and `advertisementRssi` are updated on every advertisement, **before** the admission filter, and only when the value actually changed (advertisements repeat several times a second per board; `StateFlow` subscribers shouldn't be invalidated at advertising rate). +- `deviceForKnownIdentifier` returns the same `MetaWearDevice` instance every time for one MAC, and if the scanner later sees that MAC on air the **same** instance is promoted into `discoveredDevices` — callers never observe two devices (two transports racing over one peripheral) for one identifier. +- After a rename, a connected board doesn't advertise, so the name cache is known-stale until disconnect; `noteAdvertisedName` lets UI update immediately and the next real advertisement reconciles it. + +### MetaWearDevice + +The main entry point for one board. All operations are `suspend` functions; state transitions are guarded by an atomic compare-and-set on the state flow (connection) and a `Mutex` serializing stream/log start-stop command sequences. Invalid transitions throw `MetaWearException.InvalidState`. + +``` +Disconnected → Connecting → Idle → Streaming + → Logging + → Downloading(progress) +``` + +```kotlin +sealed class DeviceState { + data object Disconnected : DeviceState() + data object Connecting : DeviceState() + data object Idle : DeviceState() + data object Streaming : DeviceState() + data object Logging : DeviceState() + data class Downloading(val progress: Double) : DeviceState() // 0.0..1.0 +} +``` + +Key members: + +```kotlin +class MetaWearDevice( + val identifier: String, // MAC address on Android + transport: BleTransport, + scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), +) { + val state: StateFlow + var deviceInfo: DeviceInformation? // populated by connect() + var modules: Map // module-discovery table from connect() + var logReferenceDate: Instant? // wall-clock instant of device tick 0 + var onUnexpectedDisconnect: ((Throwable) -> Unit)? + + suspend fun connect() + suspend fun reconnect() + suspend fun disconnect() + suspend fun sendExpectingDisconnect(command: Command, timeout: Duration = 5.seconds) + suspend fun factoryReset() + suspend fun restart() + + suspend fun startStream(sensor: Streamable, usePacked: Boolean = true): Flow> + suspend fun stopStreaming(sensor: Streamable) + + suspend fun send(command: Command) + suspend fun send(sequence: CommandSequence) + + suspend fun read(readable: Readable): Timestamped + fun poll(readable: Pollable, every: Duration): Flow> + suspend fun readRSSI(): Int + + fun moduleInfo(module: Module): ModuleInfo? + val hasGyroscope: Boolean; val hasMagnetometer: Boolean + val hasBarometer: Boolean; val hasSensorFusion: Boolean + + fun captureBoardState(): BoardState? + fun restoreBoardState(state: BoardState) +} + +// Logging surface — extension functions in DeviceLogging.kt +suspend fun MetaWearDevice.startLogging(loggable: Loggable) +suspend fun MetaWearDevice.stopLogging(loggable: Loggable) +suspend fun MetaWearDevice.startLogging(logger: PolledLogger): PolledLoggerHandles +suspend fun MetaWearDevice.stopLogging(logger: PolledLogger, handles: PolledLoggerHandles) +suspend fun MetaWearDevice.startLogging(handle: ProcessorHandle, key: String) +suspend fun MetaWearDevice.stopLogging(key: String) +suspend fun MetaWearDevice.downloadLogs(expectEntries: Boolean = false): Flow>> +suspend fun MetaWearDevice.downloadLogs(loggable: Loggable): Flow>>> +suspend fun MetaWearDevice.downloadLogs(logger: PolledLogger): Flow>>> +suspend fun MetaWearDevice.downloadLogs(key: String, decode: (ByteArray) -> S): Flow>>> +suspend fun MetaWearDevice.clearLog() +suspend fun MetaWearDevice.flushLogPage(): Boolean +suspend fun MetaWearDevice.stopOnBoardLogging() +suspend fun MetaWearDevice.queryActiveLoggers(): List +suspend fun MetaWearDevice.queryActiveProcessors(): List +suspend fun MetaWearDevice.recoverLoggers(loggable: Loggable) +suspend fun MetaWearDevice.recoverLoggers(logger: PolledLogger) +suspend fun MetaWearDevice.createAnonymousDataSignals(): List // DeviceAnonymousSignals.kt +``` + +Notes on the lifecycle: + +- `connect()` atomically moves `Disconnected → Connecting`; a concurrent `connect()` fails fast. It connects the transport, starts the protocol router, reads the Device Information Service, discovers modules, and reads the log time reference; on any failure the link is torn down so the reported `Disconnected` is the truth. +- `disconnect()` is intentional: it finishes processor flows cleanly, stops the router, and suppresses `onUnexpectedDisconnect` for that drop. An unexpected drop moves the device to `Disconnected`, fails open streams with the underlying error, and invokes `onUnexpectedDisconnect` — but **preserves the logger registry**, so a reconnect can download without re-registering. +- `sendExpectingDisconnect(command)` is for commands that reboot the board or drop the link (`Debug.Reset()`, `Debug.JumpToBootloader()`, …): it swaps in a one-shot drop signal before sending, waits for the drop (or falls back to a local teardown after `timeout`), and converges on `Disconnected` without firing `onUnexpectedDisconnect`. +- `factoryReset()` issues the seven-write scrub sequence — stop logging, drop all log entries, remove all logger triggers, remove all events, remove all data processors, erase all macros, reset-after-GC — followed by an immediate `Debug.Reset()` fallback for firmware revisions (notably MMS 1.5.0) that ignore reset-after-GC when nothing is pending. `restart()` reboots **without** erasing anything: flash-resident data (log entries, macros) survives; volatile state (timers, events, sensor enables) is cleared. Reconnect after ~1 s in both cases. +- Streaming guards: the same `(module, register)` can't be started twice; **sensor fusion and the raw IMU sensors (accelerometer / gyroscope / magnetometer) are mutually exclusive**; all simultaneous fusion outputs must share one mode/range (a second output with a different config is rejected with `InvalidState` instead of being silently ignored by the board). If a start sequence fails mid-way the sensor is rolled back so a retry isn't blocked with "already streaming". + +### BLE transport split + +The shared scan source is separate from the per-peripheral connection transport: + +| Type | Module | Role | +|-------------------------|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| `BleTransport` | `:metawear-protocol` | The seam: `scan(services)`, `connect(identifier)`, `disconnect()`, `write(data, characteristic, type)`, `read(characteristic)`, `notifications(characteristic)`, `readRSSI()` | +| `AndroidBleScanSource` | `:metawear-core` | Unfiltered BLE scan → `Flow` (`identifier` = MAC, `name`, `rssi`, `manufacturerData`, `serviceUUIDs`). Scan-only; connection calls throw. | +| `NordicBleTransport` | `:metawear-core` | One per device. GATT connect / write / read / notify / RSSI on the Nordic Kotlin BLE Library. | +| `MockBleTransport` | `:metawear-protocol` | Public in-memory transport for unit tests. Inject notifications with `inject(notification, characteristic)`; inspect `writtenCommands`. No hardware required. | +| `DemoBleTransport` | `:app` (test only) | Protocol-level MetaMotion S emulator behind the same seam (see [Demo transport (test fixture)](#demo-transport-test-fixture)). | + +`NordicBleTransport` connect flow and transport notes: + +- GATT connect with `autoConnect = false` and a bounded retry (3 attempts, 500 ms apart, 20 s timeout each) that absorbs Android's transient status-133 (`GATT_ERROR`) failures. +- Full service discovery; every characteristic of every service is indexed by UUID, so Device Information Service reads (service `0x180A`, not the MetaWear service) resolve by characteristic UUID alone. +- MTU request for **247** bytes: the default 23-byte MTU leaves a 20-byte payload, too small for packed streaming (3 samples × 6 bytes + 2-byte header, and firmware past 1.4 pads further). +- Notifications enabled on the MetaWear notify characteristic (`326A9006-…`) up front, so the router can subscribe without an extra descriptor round-trip. +- GATT operations are serialized by the Nordic client's internal per-connection mutex — Android allows only one outstanding GATT operation — so the transport adds no locking of its own. +- Write-without-response for commands; write-with-response for macro recording. + +### ProtocolRouter + +Internal to each device. Routes raw BLE notification bytes to the right handler by `(module_id, register_id & 0x3F)` key: + +- Read responses (register `| 0x80` bit set) → resume the FIFO-queued suspended reader +- Unsolicited notifications → one-shot notify waiters (I2C/SPI reads, ADD/ENTRY acknowledgements) and ongoing subscription channels (streaming sensors) +- Multiple concurrent reads on the same key are queued and resolved FIFO +- Read timeout is 5 s (`ProtocolRouter.READ_TIMEOUT`) → `MetaWearException.Timeout`; slot-enumeration probes use a 1 s timeout because the firmware never answers an empty slot +- Per-key subscription buffers hold 256 packets and drop the **oldest** on overflow (sensor notifications arrive at up to ~200 packets/s; a stalled collector must not grow memory unbounded) +- All waiters and streams are failed when `stop()` is called or the transport's notification flow terminates (disconnect) +- Module discovery reads register `0x00` of every `Module` and records `implementation`, `revision`, and `extra` bytes into `ModuleInfo` (`isPresent` = implementation ≠ `0xFF`) + +Data-processor streaming uses a per-id demux on top of the router: one shared `(0x09, 0x03)` subscription fans packets out to per-processor-id flows, so multiple processors stream simultaneously; the flows complete cleanly on an intentional disconnect and fail with the underlying error on an unexpected one. + +--- + +## Sensor protocols + +Every sensor type is a plain Kotlin value that implements one of these interfaces (`com.mbientlab.metawear.protocol`): + +```kotlin +interface Sensor { + val module: Module + val dataRegister: Int + val packedDataRegister: Int? get() = null +} + +// Streams data continuously (accelerometer, gyro, magnetometer, sensor fusion, barometer, switch, GPIO) +interface Streamable : Sensor { + val configureCommands: List + val enableCommand: ByteArray + val startCommand: ByteArray + val stopCommand: ByteArray + val disableCommand: ByteArray + val warmupCommands: List get() = emptyList() // e.g. magnetometer power-on (+ warmupDelayNanos) + fun parseSample(packet: ByteArray): S + fun parsePackedSamples(packet: ByteArray): List = emptyList() +} + +// Streams AND logs to on-device flash +interface Loggable : Streamable { + val loggerKey: String + val logDataChunks: List get() = listOf(LogChunk(0, 4), LogChunk(4, 2)) + fun parseLogSample(data: ByteArray): S +} + +// One-shot read sensors (battery, MAC, humidity, log length, …) +interface Readable : Sensor { + val readCommand: ByteArray + fun parseSample(packet: ByteArray): S +} + +// A readable whose value changes over time — works with device.poll(readable, every) +interface Pollable : Readable + +// A readable that can be logged on-board via the timer → event → logger chain +interface PolledLoggable : Readable { + val logDataChunks: List + val loggerTriggerIndex: Int get() = 0xFF +} + +// Fire-and-forget commands (LED, haptic, debug, timer control, GPIO, macro) +interface Command { + val commandData: ByteArray +} + +// Fire-and-forget actions that require more than one BLE write +// (e.g. BMI270 feature enable/disable pairs, long scan-response splits) +interface CommandSequence { + val commands: List +} +``` + +`device.send(...)` is overloaded for both `Command` and `CommandSequence` — a single call site regardless of whether the action emits one or many writes. + +### Generic read and poll + +Any `Readable` works with `device.read(...)`; any `Pollable` also works with `device.poll(readable, every)`, which returns a cold `Flow`: + +```kotlin +val humidity = device.read(Humidity()) // Timestamped, % RH +val entries = device.read(LogLength()) // Timestamped +val mac = device.read(Settings.ReadMacAddress()) // Timestamped (alias: MacAddress) +val reset = device.read(LastResetTime()) // Timestamped — { epoch: Instant, resetUID: Int } +val celsius = device.read(Thermometer(channel = 0)) // Timestamped + +device.poll(Settings.ReadBatteryState(), every = 30.seconds).collect { sample -> + updateBatteryUI(sample.value.charge, sample.value.voltage) // BatteryState(voltage mV, charge %) +} +``` + +`poll` reads the sensor immediately, emits the timestamped sample, delays `every`, and repeats. Cancelling the collecting coroutine stops the loop; a read error terminates the flow. + +Built-in `Pollable` implementations: `LoggingEnabled`, `LogLength`, `LastResetTime`, `Settings.ReadBatteryState`, `Settings.ReadMacAddress`, `Settings.ReadPowerStatus`, `Settings.ReadChargeStatus`, `Humidity`, `BarometerPressureRead`, `Thermometer`, `SensorFusionCalibrationState`. + +--- + +## Supported sensors and modules + +All 22 board modules are covered. Each subsection names the Kotlin type, the module ID, and the configuration shape. + +### Accelerometer (module 0x03) + +```kotlin +// BMI160 (MetaMotion R / RL — model 5) +// Odr: HZ0_78, HZ1_56, HZ3_12, HZ6_25, HZ12_5, HZ25, HZ50, HZ100, HZ200, HZ400, HZ800, HZ1600 +// Range: G2, G4, G8, G16 +val acc = AccelerometerBmi160(odr = AccelerometerBmi160.Odr.HZ100, range = AccelerometerBmi160.Range.G2) + +// BMI270 (MetaMotion S — model 8) +// Same Odr / Range entries as BMI160 (different config bytes on the wire) +val acc = AccelerometerBmi270(odr = AccelerometerBmi270.Odr.HZ100, range = AccelerometerBmi270.Range.G2) +``` + +Both implement `Loggable` (`loggerKey = "acceleration"`, data register `0x04`). Packed data (3 samples per BLE packet) is used automatically when `usePacked = true` (default) — packed register `0x1C` on the BMI160, `0x05` on the BMI270. Scale factors (LSB/g): ±2 g = 16384, ±4 g = 8192, ±8 g = 4096, ±16 g = 2048. + +`Accelerometer` is the type-erased sealed wrapper (`Accelerometer.Bmi160(sensor)` / `Accelerometer.Bmi270(sensor)`) with `odrHz`, `rangeG`, `withOdr(...)`, `withRange(...)`, and the `Accelerometer.make(impl, odrHz, rangeG)` factory — see [Auto-select accelerometer](#auto-select-accelerometer). + +#### Step counter / detector + +```kotlin +// BMI160 — register 0x18 config, read on 0x1A, detector notifications on 0x19 +device.send(AccelerometerBmi160Steps.ConfigureStepCounter(AccelerometerBmi160Steps.StepCounterMode.NORMAL)) // NORMAL / SENSITIVE / ROBUST +device.send(AccelerometerBmi160Steps.EnableStepDetector()) +val steps = AccelerometerBmi160Steps.parseStepCount(packet) // from a ReadStepCounter response + +// BMI270 — watermark-triggered counter (CommandSequence), detector notifications on 0x0B +device.send(AccelerometerBmi270Steps.ConfigureStepCounter(trigger = 1)) +device.send(AccelerometerBmi270Steps.EnableStepDetector()) +val steps = AccelerometerBmi270Steps.parseStepCount(packet) +``` + +### Gyroscope (module 0x13) + +```kotlin +// BMI160 (MetaMotion R / RL — model 5) +// Odr: HZ25, HZ50, HZ100, HZ200, HZ400, HZ800, HZ1600, HZ3200 +// Range: DPS2000, DPS1000, DPS500, DPS250, DPS125 +val gyro = GyroscopeBmi160(odr = GyroscopeBmi160.Odr.HZ100, range = GyroscopeBmi160.Range.DPS2000) + +// BMI270 (MetaMotion S — model 8) +// Same options as BMI160 +val gyro = GyroscopeBmi270(odr = GyroscopeBmi270.Odr.HZ100, range = GyroscopeBmi270.Range.DPS2000) + +// BMI270 only: write axis offsets +device.send(GyroscopeBmi270.Offsets(x = 0, y = 0, z = 0)) +``` + +Both implement `Loggable` (`loggerKey = "angular-velocity"`). Data register is `0x05` on the BMI160 (packed `0x07`) and `0x04` on the BMI270 (packed `0x05`). `Gyroscope` is the sealed wrapper with `Gyroscope.make(impl, odrHz, rangeDps)` (impl `0` = BMI160, `1` = BMI270 from `moduleInfo(Module.GYRO)?.implementation`). + +### Bosch motion detectors (accelerometer interrupts) + +Orientation, any-motion, and tap interrupts are generated on-chip by the BMI160 / BMI270. `AccelerometerBosch` ships the configure / enable / disable commands and the packet decoders. + +```kotlin +// Orientation — fires when the device rotates through one of 8 states (notification register 0x11) +// BMI160-only; constructing with ChipVariant.BMI270 throws MetaWearException.OperationFailed. +device.send(AccelerometerBosch.EnableOrientation(chip = AccelerometerBosch.ChipVariant.BMI160)) +val orientation = AccelerometerBosch.parseOrientation(packet) // AccelerometerBosch.SensorOrientation.FACE_UP_PORTRAIT_UPRIGHT … + +// Any-motion — fires when motion exceeds threshold on any axis (config 0x0A, enable 0x09, notify 0x0B) +device.send(AccelerometerBosch.ConfigureAnyMotion( + chip = AccelerometerBosch.ChipVariant.BMI270, count = 4, thresholdG = 0.75f, rangeG = 8.0f, +)) +device.send(AccelerometerBosch.EnableAnyMotion()) +val event = AccelerometerBosch.parseAnyMotion(packet) // event.isPositive, event.xAxisActive / yAxisActive / zAxisActive + +// Tap — single + double tap (config 0x0D, enable 0x0C, notify 0x0E) +device.send(AccelerometerBosch.ConfigureTap( + shockTime = AccelerometerBosch.TapShockTime.MS50, + quietTime = AccelerometerBosch.TapQuietTime.MS30, + doubleTapWindow = AccelerometerBosch.DoubleTapWindow.MS250, + thresholdG = 2.0f, rangeG = 8.0f, +)) +device.send(AccelerometerBosch.EnableTap(single = true, double = true)) +val tap = AccelerometerBosch.parseTap(packet) // tap.type (SINGLE / DOUBLE), tap.isPositive +``` + +To receive the interrupt packets, wrap the notification register in a small `Streamable` and use `startStream` — the interface is public and `startStream` handles the subscribe write and the accelerometer start for you: + +```kotlin +class TapInterrupt : Streamable { + override val module = Module.ACCELEROMETER + override val dataRegister = 0x0E + override val configureCommands = listOf( + AccelerometerBosch.ConfigureTap(thresholdG = 2.0f, rangeG = 8.0f).commandData, + ) + override val enableCommand = AccelerometerBosch.EnableTap(single = true, double = true).commandData + override val startCommand = Packet.command(Module.ACCELEROMETER, 0x01, 0x01) // start sampling + override val stopCommand = Packet.command(Module.ACCELEROMETER, 0x01, 0x00) + override val disableCommand = AccelerometerBosch.DisableTap().commandData + override fun parseSample(packet: ByteArray) = AccelerometerBosch.parseTap(packet) +} + +device.startStream(TapInterrupt()).collect { tap -> println(tap.value.type) } +``` + +An interrupt stream shares the accelerometer module with the data stream, so the streaming guard treats it as the same module — run one or the other, or fold the interrupt into a data processor / event instead. + +### BMI270 extra features (activity, wrist, no-motion, significant motion, downsampling) + +BMI270-only features exposed via `AccelerometerBmi270Features`. `Configure…` and `SetDownsampling` are `Command`s; the `Enable…` / `Disable…` pairs (which emit both `FEATURE_INTERRUPT_ENABLE` and `FEATURE_ENABLE` writes) are `CommandSequence`s. Both ship through the same `device.send(...)` entry point. + +```kotlin +// Activity classification — STILL / WALKING / RUNNING / UNKNOWN (register 0x0C) +device.send(AccelerometerBmi270Features.EnableActivityDetection()) +val activity = AccelerometerBmi270Features.parseActivity(packet) // AccelerometerBmi270Features.Activity + +// Wrist gesture (register 0x0A) — PUSH_ARM_DOWN, PIVOT_UP, SHAKE, ARM_FLICK_IN, ARM_FLICK_OUT +device.send(AccelerometerBmi270Features.ConfigureWristGesture(arm = AccelerometerBmi270Features.WristArm.RIGHT)) +device.send(AccelerometerBmi270Features.EnableWristGesture()) +val event = AccelerometerBmi270Features.parseWristEvent(packet) // event.kind (WAKEUP / GESTURE), event.gestureCode + +// Wrist wakeup (register 0x0A) — shares the parse path with wrist gesture +device.send(AccelerometerBmi270Features.ConfigureWristWakeup()) +device.send(AccelerometerBmi270Features.EnableWristWakeup()) + +// No-motion (register 0x09) — distinct from any-motion +device.send(AccelerometerBmi270Features.ConfigureNoMotion( + duration = 5, threshold = 0xAA, selectX = true, selectY = true, selectZ = true, +)) +device.send(AccelerometerBmi270Features.EnableNoMotion()) + +// Significant motion +device.send(AccelerometerBmi270Features.ConfigureSignificantMotion(blocksize = 250)) +device.send(AccelerometerBmi270Features.EnableSignificantMotion()) + +// FIFO downsampling — reduce the logged sample rate per axis-group +device.send(AccelerometerBmi270Features.SetDownsampling( + gyroOrdinal = 2, gyroFilterData = true, + accOrdinal = 2, accFilterData = true, +)) +``` + +Every feature has a matching `Disable…` sequence (`DisableActivityDetection()`, `DisableWristGesture()`, `DisableWristWakeup()`, `DisableNoMotion()`, `DisableSignificantMotion()`). Notification registers are exposed as constants (`ACTIVITY_REGISTER`, `WRIST_EVENT_REGISTER`, `AccelerometerBmi270Steps.STEP_REGISTER`). + +### Magnetometer (BMM150, module 0x15) + +```kotlin +val mag = Magnetometer(preset = Magnetometer.Preset.LOW_POWER) +// Presets: LOW_POWER, REGULAR, ENHANCED_REGULAR, HIGH_ACCURACY +// Manual: Magnetometer(xyReps = 9, zReps = 15, odr = Magnetometer.Odr.HZ10) // Odr: HZ2 … HZ30 +// Scale: 16 LSB/µT; Loggable, loggerKey "magnetic-field" +// Data register 0x05, packed register 0x09 (module revision ≥ 1) + +device.send(Magnetometer.Suspend()) // low-power suspend +``` + +`startStream` sends the magnetometer's warm-up (power-mode) command and waits 200 ms before configuring — the `warmupCommands` / `warmupDelayNanos` hooks on `Streamable`. + +### Barometer (BMP280 / BME280, module 0x12) + +```kotlin +// BMP280 (motion boards) +val baro = Barometer( + oversampling = Barometer.Oversampling.STANDARD, // SKIP, ULTRA_LOW_POWER, LOW_POWER, STANDARD, HIGH, ULTRA_HIGH + iirFilter = Barometer.IirFilter.AVG4, // OFF, AVG2, AVG4, AVG8, AVG16 + standbyTime = Barometer.BmpStandbyTime.MS62_5, // MS0_5 … MS4000 +) +// BME280 (environmental boards) — standby indices 6/7 differ +val baroBme = Barometer(Barometer.Oversampling.STANDARD, Barometer.IirFilter.OFF, Barometer.BmeStandbyTime.MS10) + +device.startStream(baro).collect { p -> println("${p.value} Pa") } // Loggable, register 0x01, loggerKey "pressure" +device.startStream(Altimeter(barometerConfig = baro)).collect { m -> println("${m.value} m") } // Streamable, register 0x02 + +val pressure = device.read(BarometerPressureRead()) // one-shot Pollable (barometer must be running in cyclic mode) +``` + +`moduleInfo(Module.BAROMETER)?.implementation` reports the chip variant (`Barometer.Variant.BMP280` = 0, `BME280` = 1). + +### Ambient light (LTR329, module 0x14) + +```kotlin +val als = AmbientLight( + gain = AmbientLight.Gain.X1, // X1 X2 X4 X8 X48 X96 + integrationTime = AmbientLight.IntegrationTime.MS100, // MS50 … MS400 + measurementRate = AmbientLight.MeasurementRate.MS500, // MS50 … MS2000 +) +device.startStream(als).collect { sample -> + val lux = AmbientLight.lux(sample.value) // raw Long milli-lux → Float lux + println("ambient: $lux lx") +} +``` + +`AmbientLight` is `Loggable` (`loggerKey = "illuminance"`, register `0x03`); the app decodes logged sessions to lux the same way. + +### Humidity (BME280, module 0x16 — MetaEnvironment) + +```kotlin +// One-shot read +val percent = device.readHumidity() // Float, % RH +// or through the generic pipeline +val sample = device.read(Humidity()) // Timestamped + +// Configure oversampling once per session (X1 X2 X4 X8 X16) +device.setHumidityOversampling(Humidity.Oversampling.X4) +``` + +`Humidity` is both `Pollable` and `PolledLoggable` — it can be polled live or logged on-board through a `PolledLogger` (see [Logging polled readables](#logging-polled-readables)). + +### Sensor fusion (BMM150 + BMI160/270, module 0x19) + +```kotlin +SensorFusionQuaternion(mode = SensorFusionMode.NDOF) // → Quaternion (w, x, y, z) +SensorFusionEuler(mode = SensorFusionMode.NDOF) // → EulerAngles (heading, pitch, roll, yaw) +SensorFusionGravity(mode = SensorFusionMode.IMU_PLUS) // → CartesianFloat (g) +SensorFusionLinearAcceleration(mode = SensorFusionMode.NDOF) // → CartesianFloat (g, gravity removed) +SensorFusionCorrectedAcc() // → CorrectedCartesianFloat (g, + accuracy) +SensorFusionCorrectedGyro() // → CorrectedCartesianFloat (dps, + accuracy) +SensorFusionCorrectedMag() // → CorrectedCartesianFloat (µT, + accuracy) +// Modes: NDOF (9-DOF), IMU_PLUS (6-DOF), COMPASS, M4G (SLEEP disables the engine) +// Ranges: accRange = SensorFusionAccRange.G2 … G16, gyroRange = SensorFusionGyroRange.DPS2000 … DPS250 +``` + +All seven outputs are `Loggable` (logger keys `"quaternion"`, `"euler-angles"`, `"gravity"`, `"linear-acceleration"`, `"corrected-acceleration"`, `"corrected-angular-velocity"`, `"corrected-magnetic-field"`). Several can stream simultaneously — they share one on-board engine, so `startStream` only sends the config once and adds each output's enable bit; stopping one output while others run only clears that bit. + +The fusion module is fed by the underlying accelerometer + gyroscope (+ magnetometer for `NDOF` / `COMPASS` / `M4G`) on the same board, and `startStream` / `startLogging` configures and starts those sensors for you. The BMI160 and BMI270 encode their config bytes differently, so the fusion signal needs to know which chip is on the board. Pass it via `chip` (defaults to `SensorFusionChip.BMI160`): + +```kotlin +// Auto-detect from the gyro module's implementation byte +// (0 = BMI160 on MetaMotion R / RL, 1 = BMI270 on MetaMotion S). +val chip = device.moduleInfo(Module.GYRO)?.implementation + ?.let { SensorFusionChip.fromGyroImpl(it) } + ?: SensorFusionChip.BMI160 + +val q = SensorFusionQuaternion(mode = SensorFusionMode.NDOF, chip = chip) +device.startStream(q).collect { s -> println(s.value) } +``` + +If you skip the chip argument, the SDK assumes BMI160. On a MetaMotion S (BMI270) the underlying acc/gyro would receive the wrong config bytes silently — the fusion algorithm runs, but at the wrong ODR / range — so always pass the detected chip on those boards. + +Related commands: `SensorFusionClearEnabledMask()`, `SensorFusionResetOrientation()`. Calibration lives in `SensorFusionCalibration.kt` — see [Sensor fusion calibration](#sensor-fusion-calibration). + +### LED (module 0x02) + +```kotlin +// Single channel +device.send(Led.SetPattern(Led.Color.GREEN, LedPattern.blink)) +device.send(Led.Play()) +device.send(Led.Stop()) // stop + clear (default) +device.send(Led.Stop(clearPattern = false)) // stop only +device.send(Led.Pause()) // pause without clearing +device.send(Led.Autoplay()) // play now and auto-play future patterns + +// Built-in presets (LedPattern companion) +// LedPattern.solid — always on (lowIntensity == highIntensity, never dims) +// LedPattern.blink — 50 ms on / 450 ms off +// LedPattern.breathe — 725 ms rise / 500 ms high / 725 ms fall over a 2 s pulse +// LedPattern.flash — 3 short 100 ms pulses + +// Multi-channel shorthand — resets, sets patterns, and plays in one call +device.setLed( + red = LedPattern(highIntensity = 10, riseTime = 100, highTime = 200, + fallTime = 100, pulseDuration = 800, repeatCount = 0xFF), + green = LedPattern(highIntensity = 31, riseTime = 100, highTime = 300, + fallTime = 100, pulseDuration = 800, repeatCount = 0xFF), + autoPlay = true, +) +device.stopLed() +``` + +Colors: `GREEN` (0), `RED` (1), `BLUE` (2). Intensity 0–31. `repeatCount = 0xFF` = infinite; a raw `0` is undefined behaviour on the firmware, so the encoder rewrites it to `0xFF`. `LedPattern` also has a `delay` field (ms before the pattern starts) to phase-offset channels. + +### GPIO (module 0x05) + +```kotlin +// Digital output +device.send(Gpio.SetHigh(pin = 0)) +device.send(Gpio.SetLow(pin = 0)) +device.send(Gpio.SetPull(pin = 0, pull = Gpio.Pull.UP)) // UP / DOWN / NONE + +// One-shot reads +val state: Boolean = device.readDigital(pin = 0) +val adcCount: Int = device.readAnalogADC(pin = 0) // raw 10-bit ADC count (0–1023) +val voltage: Int = device.readAnalogAbsolute(pin = 0) // millivolts (0–3300) + +// Analog reads accept optional pull-up / pull-down / virtual-pin / delay parameters +device.readAnalogADC(pin = 0, parameters = Gpio.AnalogReadParameters(pullupPin = 1, delayMicroseconds = 100)) + +// Pin-change stream +val signal = GpioPinChange(pin = 0, changeType = Gpio.ChangeType.ANY) // RISING / FALLING / ANY +device.startStream(signal).collect { sample -> + println("pin ${sample.value.pin} → ${if (sample.value.isHigh) "high" else "low"}") // GpioSample +} +``` + +Command classes are also available directly: `Gpio.ConfigurePinChange(pin, type)`, `Gpio.StartPinMonitor(pin)`, `Gpio.StopPinMonitor(pin)`, `Gpio.DigitalRead(pin, silent)`, `Gpio.AnalogRead(mode, pin, silent, parameters)`. `GpioAnalogSignal(pin, mode)` exposes an analog pin as a data-processor source. + +### Switch / button (module 0x01) + +```kotlin +device.startStream(Switch()).collect { event -> + println(if (event.value) "pressed" else "released") // Streamable +} +``` + +### Haptic (module 0x08) + +```kotlin +device.send(Haptic.motor(dutyCycle = 80, pulseWidth = 500)) // ERM motor: duty 0–100 %, pulse ms +device.send(Haptic.buzzer(pulseWidth = 200)) // piezo buzzer +// Explicit form: Haptic.Pulse(mode = Haptic.Mode.MOTOR, dutyCycle = 100, pulseWidth = 500) +``` + +### Temperature (module 0x04) + +```kotlin +val celsius = device.read(Thermometer(channel = 0)).value +// Channel sources: NRF_DIE (0), EXT_THERMISTOR, BMP280, PRESET_THERMISTOR — layout varies by board +// (MetaWear R: [NRF_DIE, EXT_THERM]; RPro: [NRF_DIE, PRESET_THERM, EXT_THERM, BMP280]) + +// Configure an external thermistor's pin mapping +device.send(ThermometerConfigureExt(channel = 1, dataPin = 0, pulldownPin = 1, activeHigh = false)) +``` + +`Thermometer(channel, silent = false)` is `Pollable` and `PolledLoggable` (`loggerTriggerIndex = channel`), so each channel can be polled live or logged on-board. `TemperatureChannel` carries named constants (`NRF`, `EXTERNAL_THERMISTOR`, `BMP280`, `BOSCH`, …) and `ThermometerSource` the source enum. The module-discovery `extra` bytes for `Module.TEMPERATURE` list the source of each channel on the connected board. + +### Timer (module 0x0C) + +On-device periodic timer — fires completely independently of BLE once started. + +```kotlin +// Create and start a 500 ms repeating timer +val timer = device.createTimer(periodMs = 500) +device.setTimerNotify(timer, enabled = true) +device.startTimer(timer) + +// Stream tick notifications over BLE (Flow of the firing timer id) +device.streamTimer(timer).collect { timerId -> /* … */ } + +// Tear down +device.stopTimer(timer) +device.setTimerNotify(timer, enabled = false) +device.removeTimer(timer) +device.removeAllTimers() // broad-stroke cleanup of all 8 timer slots + +// Parameters +device.createTimer(periodMs = 1000, repetitions = MetaWearTimer.INFINITE, immediate = false) +// repetitions: 0xFFFF = MetaWearTimer.INFINITE; immediate = true fires at t=0 +``` + +### Event (module 0x0A) + +Bind a board signal (timer tick, button press, GPIO change, disconnect) to a command that executes on-board — no BLE connection required once configured. + +```kotlin +// Flash the green LED every time the timer fires — works even if BLE disconnects +val timer = device.createTimer(periodMs = 500) +device.send(Led.SetPattern(Led.Color.GREEN, LedPattern.blink)) +val event = device.createEvent( + source = EventSource.timerFired(timer), + action = EventAction.from(Led.Play()), +) +device.startTimer(timer) + +// Other event sources +EventSource.buttonChanged() // fires on every button state change +EventSource.gpioChanged(pin = 0) // fires on GPIO pin-change notification +EventSource.disconnected() // fires when the host drops the connection (settings rev ≥ 2) +EventSource(module = Module.TIMER, register = 0x06, dataId = 0xFF) // any (module, register, id) triple + +// Tear down +device.removeEvent(event) +device.removeAllEvents() +``` + +`EventAction(module, register, params)` describes the destination command; `EventAction.from(command)` splits any `Command`'s bytes into that shape. + +#### Source → destination data slicing (`EventDataToken`) + +Optional instruction appended to the ENTRY command that tells the firmware to copy `length` bytes starting at `sourceOffset` of the source signal's payload into the destination command's params starting at `destOffset` when the event fires. Without a token the destination params are written as-is. + +```kotlin +// Route 4 bytes from source offset 2 into destination offset 3 +val event = device.createEvent( + source = EventSource.timerFired(timer), + action = action, + dataToken = EventDataToken(length = 4, sourceOffset = 2, destOffset = 3), +) +// Constraints: length 1…7 (3 bits), sourceOffset 0…15 (4 bits), destOffset any byte +``` + +### Macro (module 0x0F) + +Record a sequence of commands into device flash. Execute manually or automatically on every power-on. + +```kotlin +// Record: set pattern + play (stored in flash) +val macro = device.recordMacro( + executeOnBoot = false, + commands = listOf( + Led.SetPattern(Led.Color.GREEN, LedPattern.blink), + Led.Play(), + ), +) + +// Execute manually +device.executeMacro(macro) + +// Or record a boot macro — runs automatically on every power-on +val bootMacro = device.recordMacro( + executeOnBoot = true, + commands = listOf(Led.SetPattern(Led.Color.BLUE, LedPattern.flash), Led.Play()), +) + +// Erase all macros +device.eraseAllMacros() +``` + +Commands longer than 13 bytes are split into ADD_PARTIAL + ADD_COMMAND packets automatically. Macro writes use write-with-response. + +#### Embedding `createEvent` in a macro + +Use the body-based overload when the macro needs to embed a multi-write action — `createEvent(...)` being the primary case. The `MacroRecorder` buffers each call's wire bytes and replays them under one BEGIN…END recording session, so the firmware re-creates the event binding every time the macro runs. + +```kotlin +// Bind button → green LED flash, persisted across reboots +val macro = device.recordMacro(executeOnBoot = true) { recorder -> + recorder.send(Led.SetPattern(Led.Color.GREEN, LedPattern.flash)) + recorder.createEvent( + source = EventSource.buttonChanged(), + action = EventAction.from(Led.Play()), + ) +} +``` + +`MacroRecorder` exposes `send(command: Command)`, `send(sequence: CommandSequence)`, `sendRaw(data: ByteArray)`, and `createEvent(source, action, dataToken)`. Embedded events do not return an `Event.id` — the firmware assigns a fresh ID at replay time. Use `removeAllEvents()` (or `eraseAllMacros()` to also clear persistence) for cleanup. + +### Serial passthrough — I2C / SPI (module 0x0D) + +Communicate with external sensors or ICs wired to the MetaWear's I2C or SPI bus. + +```kotlin +// I2C write — 0x00 to register 0x6B of the device at address 0x68 (e.g. wake an MPU-6050) +device.send(Serial.I2cWrite(deviceAddress = 0x68, registerAddress = 0x6B, data = byteArrayOf(0x00))) + +// I2C read — 1 byte from register 0x75 (WHO_AM_I) of the device at 0x68 +val bytes = device.i2cRead(deviceAddress = 0x68, registerAddress = 0x75, length = 1) +println("WHO_AM_I: " + bytes.joinToString { "0x%02X".format(it) }) + +// SPI — pin/mode/clock parameter block +val spi = Serial.SpiParameters( + slaveSelectPin = 10, clockPin = 0, mosiPin = 11, misoPin = 7, + mode = Serial.SpiMode.MODE3, // MODE0 (CPOL=0/CPHA=0) … MODE3 (CPOL=1/CPHA=1) + frequency = Serial.SpiClock.F1_MHZ, // F125_KHZ F250_KHZ F500_KHZ F1_MHZ F2_MHZ F4_MHZ F8_MHZ + lsbFirst = false, useNrfPins = false, +) + +// SPI write — send 0x9F (READ_ID) +device.send(Serial.SpiWrite(parameters = spi, data = byteArrayOf(0x9F.toByte()))) + +// SPI read — 3 bytes (e.g. flash JEDEC ID), optionally writing 0x9F first +val id = device.spiRead(parameters = spi, length = 3, writeData = byteArrayOf(0x9F.toByte())) +``` + +I2C writes carry at most `Serial.I2cWrite.MAX_PAYLOAD_LENGTH` (10) bytes. Reads reply with a plain notification (not a bit-7 read response), which the device awaits for you. + +### iBeacon (module 0x07) + +```kotlin +device.send(IBeacon.SetUuid(UUID.randomUUID())) +device.send(IBeacon.SetMajor(1)) +device.send(IBeacon.SetMinor(2)) +device.send(IBeacon.SetRxPower(-55)) +device.send(IBeacon.SetTxPower(-4)) +device.send(IBeacon.SetPeriod(periodMs = 700)) +device.send(IBeacon.Enable()) +// ... +device.send(IBeacon.Disable()) +``` + +### Data processor (module 0x09) + +The data processor lets you chain on-device signal transforms so the board filters and reduces data before it ever reaches your app over BLE. + +#### Create a processor + +```kotlin +// RSS of raw accelerometer — reduces 3-axis to a scalar magnitude +val rssHandle = device.createProcessor(DataProcessor.Rss(), source = AccelerometerSignal()) + +// Average the RSS output over a 4-sample window +val avgHandle = device.createProcessor(DataProcessor.Average(sampleSize = 4), source = rssHandle) + +// Threshold — emit when the average crosses 0.5 g (= 8192 at the ±2 g 16384 LSB/g scale) +val threshHandle = device.createProcessor( + DataProcessor.Threshold(boundary = 8192, hysteresis = 0, + mode = DataProcessor.Threshold.Mode.BINARY, signed = false), + source = avgHandle, +) +``` + +The processor chain only produces output while its root sensor is sampling. Start the source either by streaming it (`startStream(acc)`) or — when you don't want the raw notifications on the air — by sending the sensor's configure / enable / start commands yourself through a tiny `Command` wrapper (this is what the app does for its prepare / teardown steps): + +```kotlin +class RawCommand(override val commandData: ByteArray) : Command + +val acc = AccelerometerBmi270(AccelerometerBmi270.Odr.HZ50, AccelerometerBmi270.Range.G2) +for (cmd in acc.configureCommands + acc.enableCommands + acc.startCommands) device.send(RawCommand(cmd)) +// … later … +for (cmd in acc.stopCommands + acc.disableCommands) device.send(RawCommand(cmd)) +``` + +#### Stream a processor's output + +```kotlin +device.streamProcessor(threshHandle).collect { packet -> + // packet = [0x09, 0x03, proc_id, data_bytes...] + val value = PacketParser.parseInt32LE(packet, 3) // BINARY threshold outputs an Int32 ±1 + println("threshold crossed: " + if (value > 0) "above" else "below") +} ``` -Opens directly in Android Studio; the Kotlin toolchain targets JDK 21. -`:metawear-core` uses AGP 9's built-in Kotlin (no `org.jetbrains.kotlin.android` -plugin — AGP 8.x does not run on this repo's Gradle 9.6). `:app` adds the -Compose compiler Gradle plugin (`org.jetbrains.kotlin.plugin.compose`) pinned -to **2.2.10 — AGP 9.2.1's embedded Kotlin compiler version**, not the 2.1.20 -used by the pure-JVM module's explicit Kotlin plugin. +#### Stop and remove + +```kotlin +device.stopStreamingProcessor(threshHandle) +device.removeProcessor(threshHandle) + +// Remove everything: +device.removeAllProcessors() +``` + +#### Available processor types + +| Type | Class | Output | +|---------------|-----------------------------|-----------------------------------------------------------| +| Passthrough | `DataProcessor.Passthrough` | Gate — `Mode.ALL` / `CONDITIONAL` / `COUNT` | +| Accumulator | `DataProcessor.Accumulator` | Running sum | +| Counter | `DataProcessor.Counter` | Event count | +| Average (LPF) | `DataProcessor.Average` | Rolling average | +| RMS combiner | `DataProcessor.Rms` | Scalar magnitude (root-mean-square) | +| RSS combiner | `DataProcessor.Rss` | Scalar magnitude (root-sum-square) | +| Time delay | `DataProcessor.Time` | Rate-limited samples (`Mode.ABSOLUTE` or `DIFFERENTIAL`) | +| Math | `DataProcessor.Math` | Arithmetic transform (`ADD MULTIPLY DIVIDE MODULO EXPONENT SQRT LSHIFT RSHIFT SUBTRACT ABS CONSTANT`) | +| Sample delay | `DataProcessor.Sample` | Burst of N buffered samples | +| Comparator | `DataProcessor.Comparator` | Filter by compare against a reference (`EQ NEQ LT LTE GT GTE`) | +| Threshold | `DataProcessor.Threshold` | Crossing events (`Mode.ABSOLUTE` or `BINARY` ±1) | +| Delta | `DataProcessor.Delta` | Emit when input changes by ≥ magnitude (`ABSOLUTE` / `DIFFERENTIAL` / `BINARY`) | +| Pulse | `DataProcessor.Pulse` | Detect pulses → `Output.WIDTH` / `AREA` / `PEAK` / `ON_DETECT` | +| Buffer | `DataProcessor.Buffer` | Hold last sample (read on demand or fused) | +| Packer | `DataProcessor.Packer` | Pack N samples per BLE packet | +| Accounter | `DataProcessor.Accounter` | Prepend timestamp (`Mode.TIME`) or packet counter (`COUNT`) | +| Fuser | `DataProcessor.Fuser` | Combine latest primary + up to 12 buffered secondaries | + +Chaining — `ProcessorHandle` implements `Signal`, so any processor's output can feed directly into the next `createProcessor` call. The handle carries the board-assigned `id` plus the output `nChannels`, `channelSize`, and `isSigned` so the next stage's config bytes are computed correctly without any manual bookkeeping. Sensor sources ship as `Signal` values: `SwitchSignal`, `AccelerometerSignal`, `GyroscopeSignal`, `GpioAnalogSignal(pin, mode)`, `TemperatureSignal(channel)`, `SensorFusionQuaternionSignal`, `SensorFusionEulerSignal`, `SensorFusionGravitySignal`, `SensorFusionLinearAccelerationSignal`. + +#### Recipes + +Common processor chains. Each recipe lists how many on-device processor slots it consumes (the board has a fixed pool — typically 28). + +**Fire on every Nth event** — count, take mod N, compare. Pair two comparators sharing the modulo output to split a stream into N classes (e.g. odd/even). + +```kotlin +// Bind switch presses +val pressed = device.createProcessor( + DataProcessor.Comparator(DataProcessor.Comparator.Operation.EQ, reference = 1, signed = false), + source = SwitchSignal()) // slot 1 + +// Counter → Math(% N) → two Comparators +val counter = device.createProcessor(DataProcessor.Counter(outputSize = 1), source = pressed) // slot 2 +val modN = device.createProcessor( + DataProcessor.Math(DataProcessor.Math.Operation.MODULO, rhs = 2, signed = false, outputSize = 1), + source = counter) // slot 3 +val isEven = device.createProcessor( + DataProcessor.Comparator(DataProcessor.Comparator.Operation.EQ, reference = 0, signed = false), + source = modN) // slot 4 +val isOdd = device.createProcessor( + DataProcessor.Comparator(DataProcessor.Comparator.Operation.EQ, reference = 1, signed = false), + source = modN) // slot 5 + +// isEven / isOdd now act as event sources: +device.createEvent( + source = EventSource(Module.DATA_PROCESSOR, register = 0x03, dataId = isEven.id), + action = EventAction.from(Led.Play()), +) +``` + +Slot cost: 5 (or 4 if you only need one of odd/even, or 3 if you don't need the press-edge filter and the source already gives you a single-sample-per-event signal). + +**Activity gate (magnitude crosses threshold)** — reduce 3-axis to scalar, then threshold or compare. Useful for activity / freefall / impact detection without streaming raw axes. + +```kotlin +val mag = device.createProcessor(DataProcessor.Rss(), source = AccelerometerSignal()) // slot 1 +val active = device.createProcessor( + DataProcessor.Threshold(boundary = 8192, // 0.5 g at ±2 g range + hysteresis = 0, + mode = DataProcessor.Threshold.Mode.BINARY, + signed = false), + source = mag) // slot 2 +// `active` emits +1 on rising edge, –1 on falling edge. +``` + +Slot cost: 2. Bind `active` as an event source to drive an LED, or feed it into `startLogging(handle, key)` (see [Logging a processor handle](#logging-a-processor-handle)) to record activity transitions to flash. + +**Throttle a high-rate signal before logging** — `DataProcessor.Time(periodMs = 1000, mode = Mode.ABSOLUTE)` on `SensorFusionEulerSignal()` takes one sample per period; log its handle with `startLogging(throttle, key = "euler-1hz")`. The full walk-through is in [Logging a processor handle](#logging-a-processor-handle). + +Slot cost: 1. `Mode.DIFFERENTIAL` outputs the *delta* between successive periods instead of a raw sample — handy for derivative-style telemetry. + +> Cleanup: chains hold each processor's slot until you call `device.removeProcessor(handle)` (or `removeAllProcessors()`). Events bound to a processor must be torn down first (`removeAllEvents()`) — events reference processors, processors reference each other, and the firmware will reject a remove that has live downstream consumers. + +### Settings (module 0x11) + +```kotlin +device.send(Settings.SetDeviceName("MySensor")) // max 26 ASCII bytes (truncates) +device.send(Settings.SetDeviceName.validating("MySensor")) // throws OperationFailed on invalid chars / length +Settings.isNameValid("MySensor") // pre-check: [A-Za-z0-9_- ], ≤ 26 + +device.send(Settings.SetTxPower(Settings.TxPower.MINUS_4)) // PLUS_4, ZERO, MINUS_4 … MINUS_40 +device.send(Settings.SetAdvertisingInterval(intervalMs = 417, timeoutSec = 0)) // 20–10240 ms; 0 = advertise forever +device.send(Settings.StartAdvertising()) +device.send(Settings.SetConnectionParameters.lowLatency) // 7.5 ms interval — high-rate streaming +device.send(Settings.SetConnectionParameters.balanced) // 30 ms +device.send(Settings.SetConnectionParameters.powerSaving) // longer interval, latency 4 +device.setScanResponse(byteArrayOf(/* raw scan-response payload */)) // long payloads split into a CommandSequence + +// Reads (all Pollable) +val battery = device.read(Settings.ReadBatteryState()).value // BatteryState(voltage, charge) +val power = device.read(Settings.ReadPowerStatus()).value // Int +val charge = device.read(Settings.ReadChargeStatus()).value // Int + +// Whitelist / radio extras (settings revision permitting) +device.send(Settings.SetWhitelistFilterMode(Settings.WhitelistFilterMode.SCAN_AND_CONNECTION_REQUESTS)) +device.send(Settings.AddWhitelistAddress(index = 0, address = Settings.BluetoothAddress.parse("AA:BB:CC:DD:EE:FF"))) +device.send(Settings.SetThreeVoltPower(enabled = true)) +device.send(Settings.SetForce1MPhy(enabled = true)) +``` + +**Verifying a device-name change.** The firmware exposes `SetDeviceName` (register 0x11/0x01) as a write-only opcode — there is no protocol read-back — and a connected board doesn't advertise. To confirm a rename took effect you must disconnect and observe the next advertisement: + +```kotlin +device.send(Settings.SetDeviceName("MySensor")) +device.disconnect() +delay(500.milliseconds) // let the radio resume advertising + +scanner.clearAdvertisedName(device.identifier) // discard pre-rename cache +scanner.startScan() +// observe scanner.advertisedNames.value[device.identifier] … +``` + +Because admission also matches the MetaWear service UUID, a renamed board keeps appearing in `discoveredDevices` — you don't need to keep the `"MetaWear"` prefix. + +### Debug (module 0xFE) + +```kotlin +device.sendExpectingDisconnect(Debug.Reset()) // soft reset (BLE drops) +device.sendExpectingDisconnect(Debug.JumpToBootloader()) // DFU mode +device.sendExpectingDisconnect(Debug.Disconnect()) // board-initiated disconnect +device.send(Debug.ResetAfterGc()) // reset after macro/log garbage collection +device.send(Debug.EnablePowerSave()) // low-power sleep +device.send(Debug.SetStackOverflowAssertion(enable = true)) +val overflow = device.read(Debug.ReadStackOverflowState()).value // OverflowState(length, assertEnabled) +val queues = device.read(Debug.ReadScheduleQueueUsage()).value // List +device.send(Debug.SpoofButtonEvent(value = 1)) // fake a button press on the board +``` + +Use `sendExpectingDisconnect` for the commands that drop the link so the device converges on `Disconnected` without firing `onUnexpectedDisconnect`. + +--- + +## Logging + +### Start / stop / download + +```kotlin +val sensor = AccelerometerBmi270(AccelerometerBmi270.Odr.HZ50, AccelerometerBmi270.Range.G2) + +device.startLogging(sensor) +// ... time passes, board logs to flash at up to 800 Hz, with or without a BLE link ... +device.stopLogging(sensor) + +// Typed download — cumulative progress + decoded samples +device.downloadLogs(sensor).collect { progress -> + println("${(progress.percentComplete * 100).toInt()}% ${progress.data.size} samples so far") +} + +device.clearLog() +``` + +- `startLogging` can be called once per sensor while the device is already `Logging` to stack multiple distinct sensors into one session; registering the same `loggerKey` twice is rejected (it would orphan a logger ID on flash and clobber the registry). +- `stopLogging` doesn't require `Logging` state (the first stop in a multi-sensor session already moved the device to `Idle`); it flushes the in-RAM page while the module is still live, stops the logging module, and sends the sensor's stop/disable commands. Logger IDs stay in the local registry so `downloadLogs` can decode afterwards. +- `downloadLogs()` (raw) moves the device to `Downloading(progress)`, force-flushes the active page, reads and settles `LOG_LENGTH`, and streams cumulative `Download>` snapshots (`data`, `percentComplete`, `totalEntries`, `entriesDownloaded`). A 60 s inactivity watchdog aborts with `MetaWearException.Timeout` if the firmware stops sending. Pass `expectEntries = true` when you *know* the board logged (local records exist) — a zero `LOG_LENGTH` then triggers a longer flush-settle wait instead of an instant empty download. +- `downloadLogs(loggable)` maps the raw flow through the registry into `Download>>`; `LoggedSample` carries `date` (wall clock), `tickMs` (ms since reset), and `value`. +- `clearLog()` drops all entries and, on MMS (logging revision ≥ 3), waits up to 60 s for the firmware's Drop-Entries completion notice so the next session doesn't arm loggers on a board still grinding through NAND garbage collection. `stopOnBoardLogging()` just stops the logging module. + +### Flushing the last log page (MMS only) + +MetaMotion S boards (logging revision ≥ 3) buffer the final partial flash page in RAM. Both `stopLogging` and `downloadLogs` call `flushLogPage()` for you, so a short session (a few seconds at low ODR) is never stranded; call it yourself if you stop the module manually. On older boards the call is a no-op and returns `false`. + +```kotlin +device.stopLogging(sensor) +val flushed = device.flushLogPage() // true on MMS, false elsewhere +``` + +### CSV export + +Any list of logged or streamed samples can be converted to a `DataTable` and exported as CSV. `DataConvertible` knows the column layout for `CartesianFloat`, `Quaternion`, `EulerAngles`, `CorrectedCartesianFloat`, `Float`, and `Boolean` (convert raw `Long` illuminance to lux `Float` first); quaternion tables gain derived `heading,pitch,roll` columns (`Quaternion.derivedEulerAngles`) matching the firmware's Euler convention. + +```kotlin +// From logged samples — columns: epoch,elapsed_ms,x,y,z +val table = DataTable.fromLogged(entries, name = "acceleration") +println(table.csvString) +table.writeCsv(File(context.cacheDir, "accel.csv")) + +// From streamed samples — columns: epoch,x,y,z +val streamed = mutableListOf>() +// ... fill from stream ... +val table = DataTable.fromStreamed(streamed, name = "acceleration") +``` + +### Sensor fusion calibration + +```kotlin +// Read calibration while sensor fusion is running (0 = unreliable … 3 = high) +val cal = device.read(SensorFusionCalibrationState()).value +println("Accel: ${cal.accelerometer} Gyro: ${cal.gyroscope} Mag: ${cal.magnetometer}") + +// Or poll it while coaching the user through the calibration motions +device.poll(SensorFusionCalibrationState(), every = 2.seconds).collect { /* … */ } + +// Persist / restore the 10-byte per-sensor calibration blobs +device.send(SensorFusionWriteAccCalibration(data.acc)) +device.send(SensorFusionWriteGyroCalibration(data.gyro)) +device.send(SensorFusionWriteMagCalibration(data.mag)) +``` + +`SensorFusionCalibrationState` needs sensor fusion revision ≥ 1. `SensorFusionCalibrationData(acc, gyro, mag)` holds the blobs. + +### Auto-select accelerometer + +```kotlin +// Picks BMI160 or BMI270 from the module table read during connect(), snapping ODR / range +// to the nearest supported value (impl 1 = BMI160, 4 = BMI270). +val impl = device.moduleInfo(Module.ACCELEROMETER)?.implementation ?: -1 +val acc = Accelerometer.make(impl, odrHz = 100.0, rangeG = 2f) // Accelerometer? (null on unknown chip) +if (acc != null) { + device.startStream(acc) // or device.startLogging(acc) +} + +// Same for the gyroscope (impl 0 = BMI160, 1 = BMI270) +val gyro = Gyroscope.make(device.moduleInfo(Module.GYRO)?.implementation ?: -1, odrHz = 100.0, rangeDps = 2000f) +``` + +### Log time anchor + +During `connect()`, the SDK reads the board's current log tick (`[0x0B, 0x84]`) and converts it to a wall-clock `Instant` stored in `device.logReferenceDate`. Downloaded `LoggedSample` values carry both a `date` (wall clock, derived from that anchor when available) and a `tickMs` (ms since device reset). `RawLogEntry.epochMs` exposes the same tick → ms conversion for raw entries. + +### Logger registry across reconnects + +Logger subscriptions survive an unexpected BLE disconnect: the board keeps the same logger IDs, and the SDK intentionally preserves `loggerRegistry` on an unexpected drop so a `reconnect()` can download without re-registering. If the *process* restarted (registry lost) but the board is still holding entries, rebuild it from the board's trigger table: + +```kotlin +device.connect() +device.recoverLoggers(sensor) // matches queryActiveLoggers() by module + register +device.downloadLogs(sensor).collect { /* … */ } +``` + +`queryActiveLoggers()` / `queryActiveProcessors()` enumerate the board's logger and processor slots (`ActiveLogger`, `ActiveProcessor`) with a 1 s per-probe timeout — the firmware never answers an empty slot, so every enumeration ends with one timed-out probe. Both take a pre-fetched enumeration overload (`recoverLoggers(sensor, active)`) when you're recovering several sensors at once. + +### Logging polled readables + +Read-only sensors (temperature, humidity, one-shot pressure) can't be streamed, but they can be logged on-board through a **timer → event → logger** chain that the SDK builds for you: the board fires the read every `periodMs` and writes each response to flash with no host involvement — it keeps working across disconnects and app close. + +```kotlin +val logger = PolledLogger(readable = Thermometer(channel = 0), periodMs = 1_000) + +val handles: PolledLoggerHandles = device.startLogging(logger) // timerID, eventID, loggerIDs — persist these! +// ... time passes ... +device.stopLogging(logger, handles) // stop + remove timer, remove event, stop logging +device.downloadLogs(logger).collect { progress -> /* LoggedSample */ } +device.clearLog() + +// After an app restart with the board still logging: +device.recoverLoggers(logger) // rebuilds the registry from queryActiveLoggers() +``` + +Anything implementing `PolledLoggable` works: `Thermometer`, `Humidity`, or your own adapter over a one-shot read register (the app's `PolledPressure` does this for barometer pressure). + +### Board state capture / restore -### Hardware smoke tests +After a full `connect()` handshake you can persist the discovered device information, module table, and log time anchor, and pre-populate a fresh `MetaWearDevice` with them before it connects — so an app can render model / firmware / module details for a remembered board while it is still offline. -`:metawear-core` ships an instrumented smoke suite that exercises a real -MetaMotion S (BMI270) end-to-end: scan → connect (device info + module -discovery) → battery read → LED pattern → 2 s accelerometer stream (~1 g at -rest) → disconnect. +```kotlin +// After connect(), snapshot current state +val state = device.captureBoardState() ?: return +val json: String = state.encode() // JSON text +// …persist `json` to DataStore / SharedPreferences / Room / a file… -1. Connect an Android phone with USB debugging enabled (API 31+). +// Next session, before connect(): +val restored = BoardState.decode(json) +device.restoreBoardState(restored) // throws OperationFailed if not disconnected +// device.deviceInfo / device.modules / device.logReferenceDate are now populated offline + +device.connect() // refreshes them from the live board +if (!restored.isCompatible(device.deviceInfo!!)) { // firmware or hardware revision changed + // invalidate anything you derived from the cached state +} +``` + +`BoardState` is a value type with `equals`/`hashCode`; its JSON schema is versioned (`BoardState.CURRENT_SCHEMA_VERSION`) so caches written by a newer SDK are rejected safely (`decode` throws `OperationFailed` when the stored schema is newer than the SDK supports). `restoreBoardState` performs no compatibility validation of its own — that's what `isCompatible(liveInfo)` is for. + +### Anonymous signals (recover loggers the SDK didn't configure) + +If the board still holds active loggers and data processors from a prior session — or from another SDK — call `createAnonymousDataSignals()` to reconstruct `List` with canonical identifiers and typed decode closures, then download as normal. + +```kotlin +device.connect() +val signals = device.createAnonymousDataSignals() +for (sig in signals) { + println("${sig.identifier} → loggers ${sig.loggerIDs} root ${sig.rootModule}") +} + +// Each AnonymousSignal exposes: +// .identifier — canonical name (e.g. "acceleration", "angular-velocity:rms?id=0") +// .rootModule — underlying Module the chain reads from +// .chunks — List; .loggerIDs = chunks.map { it.id } +// .decode(ByteArray) -> List +// Output = Cartesian | Scalar | Quaternion | Euler | CorrectedCartesian +``` + +Wiring downloaded log entries into a signal's decode closure means grouping `RawLogEntry.rawData` bytes by `loggerIDs` and feeding each reassembled payload to `decode`. The app's `AnonymousSignalDecoder` (`data/ForeignLog.kt`) does exactly this to download foreign logs. + +Scale factors (accel / gyro range) are read from the live board at call time (`[0x03, 0x83]`, `[0x13, 0x83]`) — if you change range afterward, call `createAnonymousDataSignals()` again. + +### Logging a processor handle + +The typed `startLogging(loggable)` overload is sensor-shaped — it reads `Loggable.logDataChunks` and parses samples back via `parseLogSample`. For the output of a data-processor chain (throttle, RMS, accumulator, fuser, …) the SDK exposes a key-based overload that takes the `ProcessorHandle` directly and lets you supply your own decoder at download time: + +```kotlin +// Throttle 100 Hz Euler fusion down to 1 Hz, log 10 s, then download. +val euler = SensorFusionEuler(mode = SensorFusionMode.NDOF, chip = SensorFusionChip.BMI270) +for (cmd in euler.configureCommands + euler.enableCommands + euler.startCommands) device.send(RawCommand(cmd)) + +val throttle = device.createProcessor( + DataProcessor.Time(periodMs = 1000, mode = DataProcessor.Time.Mode.ABSOLUTE), + source = SensorFusionEulerSignal(), +) +val key = "euler-throttle-1hz" +device.startLogging(throttle, key = key) +delay(10.seconds) +device.stopLogging(key = key) +for (cmd in euler.stopCommands + euler.disableCommands) device.send(RawCommand(cmd)) +device.flushLogPage() // MMS only (no-op elsewhere) + +device.downloadLogs(key = key) { data -> + euler.parseLogSample(data) // any (ByteArray) -> S decoder +}.collect { progress -> + println("${progress.percentComplete} ${progress.data.size} samples") +} +``` + +What's going on: + +- `startLogging(handle, key)` splits the handle's full output (`nChannels × channelSize`) into ≤ 4-byte chunks (the firmware's per-entry byte limit), issues one `[0x0B, 0x02, 0x09, 0x03, proc_id, packed]` subscribe per chunk, registers the resulting logger IDs under `key`, and transitions the device to `Logging`. It does not touch any sensor lifecycle — starting and stopping the source that feeds the chain is your job. +- `downloadLogs(key, decode)` reassembles entries by logger-ID order and hands each reconstructed payload to your decoder. + +Sensor-fusion outputs are exposed as `Signal` values for use as processor sources: `SensorFusionEulerSignal`, `SensorFusionQuaternionSignal`, `SensorFusionGravitySignal`, `SensorFusionLinearAccelerationSignal`. + +--- + +## Firmware updates + +`:metawear-firmware` is a separate module so apps that never flash firmware don't pull in the Nordic DFU library. Every update entry point returns a cold `Flow`: drive a progress bar from the same collection that catches failure, and cancel the collector to abort the transfer. + +### Check and update a connected board + +```kotlin +import com.mbientlab.metawear.firmware.checkForFirmwareUpdate +import com.mbientlab.metawear.firmware.updateFirmwareToLatest + +// Is something newer on the MbientLab release catalog? +val build = device.checkForFirmwareUpdate() // FirmwareBuild? — null when already current +if (build != null) println("Update available: ${build.firmwareRev}") + +// Flash the latest release. The flow finishes after FETCHING_CATALOG with no COMPLETED event +// if the board is already current (pass forceReinstall = true to reflash regardless). +device.updateFirmwareToLatest(context).collect { progress -> + println("${progress.state} ${progress.percentComplete.toInt()}% part ${progress.currentPart}/${progress.totalParts}") +} + +// The flash reboots the board and leaves the cached device state stale: +device.connect() +``` + +`DFUProgress.State` walks `FETCHING_CATALOG → DOWNLOADING_FIRMWARE → BOOTLOADER_HANDOFF → SCANNING → CONNECTING → STARTING → UPLOADING → VALIDATING → DISCONNECTING → COMPLETED` (or `ABORTED`); `percentComplete` is only populated during `UPLOADING`, and `bytesPerSecond` reports the transfer rate. + +`updateFirmware(context, zipUrl)` flashes an explicit firmware file instead of the catalog build — an `https://` URL or a `file://` URI; `.zip` (Nordic DFU distribution package), or raw `.bin` / `.hex`. Either path handles the bootloader handoff itself: it sends `[0xFE, 0x02]` via `sendExpectingDisconnect(Debug.JumpToBootloader())`, waits for the board to drop the link as it reboots into MetaBoot, then runs the Nordic DFU transfer against the same MAC address. + +An outdated bootloader is handled automatically on the catalog path: after the handoff, `MetaBootProbe` reads the installed bootloader version from MetaBoot's Device Information service, `BootloaderInterlock` compares it against the build's `required-bootloader`, and the needed bootloader-flavor flash(es) are chained before the application stage. Multi-stage flashes surface through `DFUProgress.currentPart` / `totalParts`; `COMPLETED` is emitted only when the final stage finishes. + +`FirmwareServer` is the catalog client (`availableBuilds`, `latestBuild`, `build`, `updateAvailable`, `downloadFirmware`; default catalog `https://mbientlab.com/releases/metawear/info2.json`), `FirmwareBuild` describes one artifact (`hardwareRev`, `modelNumber`, `buildFlavor`, `firmwareRev`, `filename`, `requiredBootloader`, `firmwareUrl`), and `FirmwareException` is the sealed error taxonomy (`BadServerResponse`, `NoAvailableFirmware`, `InvalidFirmwareFile`, `BootloaderUpgradeUnavailable`, `DeviceNotIdle`, `DfuFailed`, `Aborted`, …). + +### Android specifics + +- The DFU transfer runs inside an Android service. The library declares `MetaWearDfuService` (a `DfuBaseService` subclass) in its manifest, and manifest merging carries the registration into your app — nothing to add unless you subclass it for a foreground notification target, in which case also create Nordic's notification channel via `DfuServiceInitiator.createDfuNotificationChannel(context)` first. +- Firmware updates need the same `BLUETOOTH_CONNECT` runtime permission as the rest of the SDK. +- The catalog JSON is parsed by a minimal hand-rolled parser (Android's `org.json` is stubbed out on the JVM unit-test classpath), keeping all **66 firmware tests** — version compare, catalog selection, server/mock-fetcher, bootloader interlock — plain JVM unit tests. The `DfuSession` / `MetaBootProbe` wrappers are hardware-only and carry no unit-test coverage. + +### Boards stuck in MetaBoot + +A board whose application flash never completed sits in bootloader mode: it advertises as **"MetaBoot"** with the Nordic DFU service instead of the MetaWear service, so `MetaWearScanner` deliberately does not admit it and the normal connect flow can't talk to it. The SDK's DFU path recovers such a board when it already knows the MAC (the `updateFirmware*` flows scan for the bootloader by address after the handoff), but there is no standalone MetaBoot scan mode or rescue-by-address entry point yet — see [What's not yet implemented](#whats-not-yet-implemented). + +--- + +## Persistence (Room) + +`:metawear-persistence` stores downloaded log sessions in a Room database (`com.mbientlab.metawear.persistence`). It depends on `:metawear-protocol` and ships its own JVM test suite (46 tests, against an in-memory fake of the DAO) plus an instrumented suite (13 tests) that re-checks what the fake can only mirror — the generated SQL's sort orders and counts, the foreign-key cascade, and the epoch-millis converter — against a real Room database on-device. + +### One database per app, one store per call site + +```kotlin +import com.mbientlab.metawear.persistence.* + +val db = PersistenceDatabase.create(context) // or PersistenceDatabase.createInMemory(context) for tests +val store = PersistenceStore(db.persistenceDao()) +``` + +All `PersistenceStore` methods are `suspend` functions over the Room DAO. + +### Save a download session + +```kotlin +val samples: List> = collectAllSamples(device.downloadLogs(sensor)) // your code + +val snapshot = store.saveSession( + deviceID = device.identifier, + deviceInfo = device.deviceInfo!!, + sensorKind = CartesianFloatPersistable.persistenceKind, // "cartesian" + samples = samples, + persistable = CartesianFloatPersistable, + label = "Accelerometer · ±2 g · 50 Hz", // optional display hint + deviceName = scanner.advertisedNames.value[device.identifier], +) +println("Saved session ${snapshot.id} with ${snapshot.sampleCount} samples") +``` + +`SessionSnapshot` is a plain immutable value (`id`, `deviceID`, `sensorKind`, `startDate`, `endDate`, `sampleCount`, `deviceSerial`, `deviceModel`, `deviceFirmware`, `label`, `deviceName`, `groupID`) — safe to hand to Compose. The Room `@Entity` types (`SessionRecord`, `SampleRecord`, with a cascade foreign key so deleting a session deletes its samples) stay inside the store. + +### Fetch / reconstruct / export + +```kotlin +// All sessions for one device, newest first (or store.fetchAllSessions()) +val sessions = store.fetchSessions(deviceID = device.identifier) + +// Rehydrate typed samples +val acceleration = store.fetchSamples(sessionID = snapshot.id, persistable = CartesianFloatPersistable) + +// One-step CSV — columns epoch,elapsed_ms,x,y,z +val table = store.exportTable(sessionID = snapshot.id, persistable = CartesianFloatPersistable) +table.writeCsv(File(context.cacheDir, "session.csv")) +``` + +`fetchSamples` and `exportTable` validate that the session's `sensorKind` matches the requested codec and throw `PersistenceException.KindMismatch` otherwise; `saveSession` with zero samples throws `PersistenceException.EmptySampleSet`; unknown IDs throw `PersistenceException.SessionNotFound`. + +### Delete + +```kotlin +store.deleteSession(id = snapshot.id) +store.deleteAllSessions(deviceID = device.identifier) +store.deleteAll() +``` + +### Supported sample types + +`Persistable` is a small codec interface that pairs a `persistenceKind` discriminator with a flat `(f0, f1, f2, f3, accuracy)` packing (`PersistedValues`). One singleton codec ships per SDK sample type — `FloatPersistable` (`"float"`), `BoolPersistable` (`"bool"`), `CartesianFloatPersistable` (`"cartesian"`), `CorrectedCartesianFloatPersistable` (`"corrected-cartesian"`), `QuaternionPersistable` (`"quaternion"`), `EulerAnglesPersistable` (`"euler"`) — in `PersistableConformances.kt`. `Instant`s persist as epoch milliseconds; sessions are identified by store-assigned UUID strings and devices by their Android MAC identifier. Adding a new sensor type means adding one codec object. + +--- + +## Data modes + +### Streaming (live) + +BLE delivers data as fast as the connection interval allows (~100 Hz practical aggregate on most phones — the app's `BandwidthAdvisor` warns past that). Packed mode sends 3 samples per BLE packet, tripling effective throughput for IMU sensors. + +``` +MetaWear → BLE notifications (packed, ~33/sec at 100 Hz) + → unpack 3 samples per notification + → Flow> +``` + +### Logging (on-device flash) + +Sensors log to flash at up to 800+ Hz independent of BLE. Download when done. + +``` +MetaWear flash → BLE burst download (1 or 2 entries per notification) + → parse 9-byte log entries (tick → epoch) + → Flow>>> +``` + +Log entry format (9 bytes, `PacketParser.parseLogEntry`): +``` +Byte 0: (reset_uid[2:0] << 5) | log_id[4:0] +Bytes 1–4: tick (32-bit LE, ~1.465 ms/tick) +Bytes 5–8: raw sensor data (32-bit LE) +``` + +A single-entry readout notification is therefore 2 (header) + 9 = 11 bytes; a paired one is 20. Tick math: `PacketParser.MS_PER_TICK = (48.0 / 32768.0) × 1000 ≈ 1.4648 ms/tick`. Signals wider than 4 bytes are split into ≤ 4-byte chunks (`LogChunk(offset, length)`), one logger ID per chunk, and reassembled in logger-ID order on download. + +--- + +## BLE packet format + +``` +Byte 0: module_id (e.g. 0x03 = accelerometer) +Byte 1: register_id (| 0x80 for READ requests; response echoes this bit set; + | 0x40 = "silent" read used by event-triggered / polled-logger reads) +Byte 2+: payload (little-endian) +``` + +Commands → `command` characteristic (`Uuids.command`, `326A9001-85CB-9195-D9DD-464CFBBAE75A`), write-without-response. Responses/notifications → `notify` characteristic (`Uuids.notify`, `326A9006-…`). The MetaWear service is `326A9000-…`. Macros use write-with-response. Device Information Service reads (`0x180A`: model `0x2A24`, serial `0x2A25`, firmware `0x2A26`, hardware `0x2A27`, manufacturer `0x2A29`) resolve by characteristic UUID. + +Module IDs (`com.mbientlab.metawear.protocol.Module`): + +| ID | Module | ID | Module | +|---|---|---|---| +| 0x01 | Switch | 0x0D | Serial (I2C / SPI) | +| 0x02 | LED | 0x0F | Macro | +| 0x03 | Accelerometer | 0x11 | Settings | +| 0x04 | Temperature | 0x12 | Barometer | +| 0x05 | GPIO | 0x13 | Gyroscope | +| 0x07 | iBeacon | 0x14 | Ambient light | +| 0x08 | Haptic | 0x15 | Magnetometer | +| 0x09 | Data processor | 0x16 | Humidity | +| 0x0A | Event | 0x19 | Sensor fusion | +| 0x0B | Logging | 0xFE | Debug | +| 0x0C | Timer | | | + +`Packet.command(module, register, vararg payload)` and `Packet.read(module, register, …)` build outgoing bytes; `PacketParser` holds the little-endian decoders (`parseInt16LE`, `parseUInt32LE`, `parseFloat32LE`, `parseCartesianFloat`, `parsePackedCartesianFloat`, `parseQuaternion`, `parseEulerAngles`, `parsePressure`, `parseAltitude`, `parseTemperature`, `parseIlluminance`, `parseHumidity`, `parseMacAddress`, `parseBatteryState`, `parseLogEntry`, …). + +--- + +## Testing + +### Unit tests (no hardware required) + +```bash +./gradlew :metawear-protocol:test +./gradlew :metawear-persistence:testDebugUnitTest +./gradlew :metawear-firmware:testDebugUnitTest +./gradlew :app:testDebugUnitTest +# or target one suite: +./gradlew :metawear-protocol:test --tests '*TimerTest*' +``` + +Four JVM test source sets ship with the repo — **1275 tests** in total: + +- **`:metawear-protocol`** — 1043 tests across 43 files. The full SDK surface, run against `MockBleTransport`, including reference byte vectors from the MetaWear C++ SDK's Python test suite. No hardware required. +- **`:metawear-persistence`** — 46 tests (`PersistableConformanceTest`, `PersistenceStoreTest`, `SessionExportTest`, `AttributionStampTest`) against an in-memory fake DAO — no hardware, no on-disk side effects. +- **`:metawear-firmware`** — 66 tests (`BootloaderInterlockTest`, `DFUProgressTest`, `FirmwareBuildTest`, `FirmwareCatalogTest`, `FirmwareExceptionTest`, `FirmwareServerTest`, `MetaWearVersionTest`). +- **`:app`** — 120 tests: ring buffer / decimation / effective-Hz math, quaternion cube and tare frame, CSV exporters, session grouping, foreign-log decisions, group-capture coordination, and end-to-end walks through the real `MetaWearDevice` against the test-only `DemoBleTransport`. + +`:metawear-protocol` coverage by file (ordered roughly by dependency): + +| Suite file(s) | What it covers | +|------------------------------------------------------------|--------------------------------------------------------------------------------------| +| `protocol/PacketBuilderTest`, `PacketParserTest`, `ProtocolRegressionTest` | Command / read packet construction; raw byte → Kotlin type parsing for all sensors; wire vectors from the C++ SDK test suite | +| `protocol/ProtocolRouterTest` | Notification routing, module discovery, concurrent reads, timeouts | +| `transport/MockBleTransportTest`, `UuidsTest` | Mock transport semantics, in-band termination; UUID constants | +| `model/BoardModelTest`, `BoardStateTest` | Model detection; capture / restore of discovered modules, JSON round-trip, schema versioning | +| `model/DataTableTest`, `AnonymousSignalTest` | CSV table construction and export, derived Euler; reconstruction of unknown loggers, chunk partitioning, identifier scheme | +| `sensor/AccelerometerCommandTest`, `AccelerometerBoschTest`, `AccelerometerBmi270FeaturesTest` | BMI160 / BMI270 config bytes and packed registers; orientation / any-motion / tap / step; activity, wrist, no-motion, significant motion, downsampling | +| `sensor/GyroscopeCommandTest`, `MagnetometerCommandTest` | Gyro config bytes, ranges, offsets; magnetometer presets, manual config, suspend, warm-up | +| `sensor/BarometerCommandTest`, `AmbientLightTest`, `HumidityTest`, `TemperatureTest` | BMP280 / BME280 config, altimeter, pressure read; LTR329 config + lux; humidity read + oversampling; channel / silent reads, ext-thermistor config, polled logging | +| `sensor/SensorFusionCommandTest`, `SensorFusionTest` | Mode / range / chip config bytes, seven outputs, shared engine, calibration | +| `sensor/LedTest`, `SwitchHapticTest`, `GpioTest` | LED pattern bytes and presets, `setLed` / `stopLed`; switch stream, haptic pulses; GPIO outputs, one-shot reads, analog parameters, pin-change stream | +| `sensor/TimerTest`, `EventTest`, `MacroTest` | Timer create/start/stop/remove, tick stream; event sources, ENTRY format, data token; recordMacro (list + body), ADD_PARTIAL, execute, erase | +| `sensor/SerialTest`, `IBeaconTest` | I2C / SPI write + read bytes and response parsing; iBeacon UUID / major / minor / power / period bytes | +| `sensor/DataProcessorTest`, `DataProcessorDemuxTest` | ADD command bytes and config bits for all 17 processor types; per-id demux | +| `sensor/SettingsTest`, `DebugTest`, `MiscReadablesTest` | Device name validation, TX power, advertising, connection params, whitelist, reads; debug commands; `LogLength`, `LastResetTime`, `LoggingEnabled`, MAC | +| `MetaWearDeviceTest`, `MetaWearScannerTest` | State machine, connect/disconnect, streaming guards, fusion / IMU conflicts; admission rule, name / RSSI caches, known-device promotion | +| `GenericReadPollTest` | Generic `device.read` and `device.poll` for `Readable` / `Pollable` | +| `LoggingTest`, `PolledLoggingTest`, `LogFinishingTest` | startLogging commands, `RawLogEntry` parsing, chunk config, download, clearLog; timer → event → logger chain and recovery; log time anchor, registry persistence | +| `DeviceAnonymousSignalsTest`, `FactoryResetTest` | Live-board scale reads and signal reconstruction; seven-write reset sequence, fallback, post-reset state | +| `ProductionGapTest` | Concurrent reads, multi-sensor streaming, reconnect, device-name validation, edge cases | + +### MockBleTransport + +Inject notifications and inspect written commands in tests: + +```kotlin +val transport = MockBleTransport().apply { + setReadResponse(Uuids.manufacturerName, "MbientLab".toByteArray()) + setReadResponse(Uuids.modelNumber, "8".toByteArray()) + setReadResponse(Uuids.serialNumber, "A0B1C2".toByteArray()) + setReadResponse(Uuids.firmwareRevision, "1.7.3".toByteArray()) + setReadResponse(Uuids.hardwareRevision, "0.1".toByteArray()) +} +val device = MetaWearDevice("AA:BB:CC:DD:EE:FF", transport, backgroundScope) + +// Inject a response to a read command +transport.inject(bytes(0x0C, 0x82, 0x01), Uuids.notify) + +// Inspect what the device wrote +val cmds: List = transport.writtenCommands // or writtenData for (data, characteristic, type) +transport.clearWrites() + +// Fault injection +transport.connectError = MetaWearException.BluetoothPoweredOff +transport.simulateDisconnect() // fails open streams / waiters +transport.mockRssi = -70 +``` + +`bytes(…)` is the test helper `internal fun bytes(vararg v: Int): ByteArray` — see [Design notes](#design-notes) for why. + +### Hardware integration tests + +Hardware tests require a real MetaWear nearby. They live in `:metawear-core`'s `androidTest` source set and run through `connectedAndroidTest` on a phone with USB debugging enabled; the test runner grants the Bluetooth permissions itself (`GrantPermissionRule`). + +1. Connect an Android phone with USB debugging enabled (API 31+ recommended). 2. Charge a MetaMotion S and place it within BLE range. 3. Run: @@ -315,30 +1752,74 @@ JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" \ ./gradlew :metawear-core:connectedAndroidTest ``` -The suite scans for the first advertised "MetaWear" name for 10 s and **skips -itself cleanly** (JUnit assumption) when no board is in range, so the task is -safe to run on benches without hardware. With several boards in range it picks -the lowest MAC for run-to-run stability. +`HardwareSmokeTest` alone exercises a real MetaMotion S (BMI270) end-to-end: scan → connect (device info + module discovery) → battery read → LED pattern → 2 s accelerometer stream (~1 g at rest) → disconnect. `HardwareSupport` scans for the first advertised `"MetaWear"` name for 10 s and every test **skips itself cleanly** (JUnit `assumeTrue`) when no board is in range, so the task is safe to run on benches without hardware. With several boards in range it picks the lowest MAC for run-to-run stability. Modules that are not present on the connected board (e.g. humidity on a board without a BME280) are skipped within each test. The HTML report lands in `metawear-core/build/reports/androidTests/connected/`. + +Hardware suites — 43 tests across 11 files, verified on a Pixel 7 + MetaMotion S: + +| Suite | What it covers | +|------------------------------------|------------------------------------------------------------------------------------------------------------------| +| `HardwareSmokeTest` | Scan finds a MetaWear with MAC identifier + sane RSSI; connect reaches `Idle` with device info + modules; battery read; green LED flash plays and stops; packed accelerometer stream ~1 g at rest; disconnect returns to `Disconnected` | +| `GyroscopeBmi270HardwareTest` | Unpacked stream then back to `Idle`; packed stream at higher throughput | +| `MagnetometerHardwareTest` | Low-power stream; high-accuracy preset gives a plausible Earth field; packed stream and suspend when the module revision supports them | +| `SensorFusionHardwareTest` | Quaternion stream then `Idle`; unit-magnitude quaternion; gravity ~1 g; quaternion + Euler together on the shared engine | +| `SwitchHardwareTest` | Switch stream starts and stops without error | +| `HapticHardwareTest` | Motor pulse, buzzer pulse, motor at max duty cycle | +| `GpioHardwareTest` | Digital read with pull-up / pull-down, 10-bit ADC, absolute millivolts on the rail, pin-change stream lifecycle | +| `SettingsHardwareTest` | Realistic battery, device name set / restore, every TX power level, RSSI while connected, start advertising | +| `EnvironmentSensorHardwareTest` | NRF die temperature, barometer pressure, altimeter, humidity (skips when not fitted) | +| `ReadHardwareTest` | Generic `device.read` round-trip for every temperature channel, battery, last reset time, log length, MAC | +| `LoggingHardwareTest` | Accelerometer log → download round trip, log length zero after clear / increasing while logging, `queryActiveLoggers` empty after clear | + +The persistence module's instrumented suite (`PersistenceDatabaseTest`, 13 tests) needs only a phone, not a board: + +```bash +./gradlew :metawear-persistence:connectedAndroidTest +``` + +See [`HARDWARE.md`](HARDWARE.md) for the full step-by-step procedure (phone setup, `adb`, the suites, and a manual app pass), plus troubleshooting. + +--- + +## Demo transport (test fixture) + +`DemoBleTransport` (`app/src/test/kotlin/com/mbientlab/metawear/app/demo/`) is a protocol-level MetaMotion S emulator behind the same `BleTransport` seam the real transport implements. It lives in the app's **test source set only** — the shipping app has no demo mode and never constructs it. It emulates a connected MetaMotion S on firmware 1.7.3: module discovery, Device Information / battery / MAC / temperature / humidity / pressure / illuminance / log reads, synthetic waveforms on every sensor including the packed registers, all seven fusion outputs, altitude and ambient light, and full logging round trips — streamed sensors, the polled timer / event / logger chain, and logger recovery across a simulated process restart. + +- The JVM tests build a `MetaWearDevice` directly over it (`MetaWearDevice(identity.identifier, DemoBleTransport(scope, identity), scope)`) and drive the real device stack — connect, discovery, streaming, logging, download — with no hardware and no Android runtime. +- `DemoBleTransport.Identity.board(index)` mints up to 16 distinguishable identities (MAC, serial, waveform phase offset), so `GroupCaptureCoordinatorTest` walks a simulated three-board fleet end-to-end against the real persistence store. +- The class is pure Kotlin (no Android imports); `DemoBleTransportTest` and `DemoIdentityTest` cover its protocol fidelity and identity minting. + +The 3D orientation view is deliberately a Canvas wireframe cube: only the quaternion frame math is load-bearing, and it stays dependency-free. + +--- ## Design notes -- **`ByteArray` equality** is reference-based in Kotlin — tests compare command - bytes with `assertArrayEquals`, never `==`; value classes wrapping `ByteArray` - (`ScanResult`, `MockBleTransport.Write`) override `equals` with `contentEquals`. -- Bytes are built from `Int` literals via a `bytes(…)` test helper to avoid the - `byteArrayOf(0x80)` "does not fit in Byte" compile error. -- `MockBleTransport` notification flows deliver termination **in-band** (as - sealed events through the channel) rather than via `Channel.close(cause)`: - closing a channel does not wake a receiver parked on a coroutines-test - `backgroundScope` dispatcher, while `trySend` does. In-band markers also - guarantee that packets buffered before a failure are delivered first, then - the failure is thrown. -- In tests, prefer `runCurrent()` (or suspending the test body) over - `advanceUntilIdle()` when the thing you're waiting on runs in - `backgroundScope` — `advanceUntilIdle` only drains foreground tasks. -- The protocol router's read timeout uses `withTimeout` + - `invokeOnCancellation` (atomic with respect to resume), so the waiter map is - simply pruned — no tombstoning needed to stay consistent under cancellation. -- Expected-failure `async` blocks in tests catch inside the block - (`async { runCatching { … } }`) — a failed `async` child cancels the test - scope even if the `await` is wrapped. +Kotlin / coroutines details the codebase relies on — read these before touching the tests: + +- **`ByteArray` equality** is reference-based in Kotlin — tests compare command bytes with `assertArrayEquals`, never `==`; value classes wrapping `ByteArray` (`ScanResult`, `MockBleTransport.Write`) override `equals` with `contentEquals`. +- Bytes are built from `Int` literals via a `bytes(…)` test helper to avoid the `byteArrayOf(0x80)` "does not fit in Byte" compile error (in production code use `0x9F.toByte()` or `Packet.command(...)`, which takes `Int` varargs). +- `MockBleTransport` notification flows deliver termination **in-band** (as sealed events through the channel) rather than via `Channel.close(cause)`: closing a channel does not wake a receiver parked on a coroutines-test `backgroundScope` dispatcher, while `trySend` does. In-band markers also guarantee that packets buffered before a failure are delivered first, then the failure is thrown. +- In tests, prefer `runCurrent()` (or suspending the test body) over `advanceUntilIdle()` when the thing you're waiting on runs in `backgroundScope` — `advanceUntilIdle` only drains foreground tasks. +- The protocol router's read timeout uses `withTimeout` + `invokeOnCancellation` (atomic with respect to resume), so the waiter map is simply pruned — no tombstoning needed to stay consistent under cancellation. +- Expected-failure `async` blocks in tests catch inside the block (`async { runCatching { … } }`) — a failed `async` child cancels the test scope even if the `await` is wrapped. +- Subscription and processor channels use `BufferOverflow.DROP_OLDEST` (256 packets): a stalled collector sheds the oldest samples rather than growing memory without bound. +- `MetaWearDevice` and `MetaWearScanner` take a `CoroutineScope` (default: a process-lifetime `SupervisorJob() + Dispatchers.Default`); pass a narrower scope (e.g. a ViewModel's) to tie SDK work to a lifecycle. Tests pass `runTest`'s `backgroundScope` for virtual time. + +--- + +## What's not yet implemented + +| Gap | Blocks | Workaround | +|---|---|---| +| Standalone MetaBoot scan mode / rescue-by-address firmware flash | Reflashing a board that is stranded in bootloader mode when you don't already have a `MetaWearDevice` for it | The `updateFirmware*` flows recover a board whose MAC you know once the handoff has happened; otherwise reflash with the Nordic nRF Connect app | +| First-class `Streamable` wrappers for the accelerometer interrupt registers (orientation, any-motion, tap, activity, wrist, no-motion, step) | Receiving interrupt packets without writing an adapter | Wrap the notification register in a small `Streamable` (see [Bosch motion detectors](#bosch-motion-detectors-accelerometer-interrupts)) — the commands and decoders ship | +| A `prepareSignalSource` / `teardownSignalSource` helper (start a sensor as a processor source without a live stream) | One-liner source setup for processor chains and processor-handle logging | Send the sensor's `configureCommands` / `enableCommands` / `startCommands` through a `Command` wrapper (see [Data processor](#data-processor-module-0x09)) | +| Charging-status as a `Loggable` | Persisting charge transitions to flash | `device.poll(Settings.ReadChargeStatus(), every = …)` covers the live-observation case | +| Maven Central publication | `implementation("com.mbientlab:metawear-…")` from a plain Gradle build | Include the modules from source or use a composite build (see [Add the SDK to your build](#add-the-sdk-to-your-build)) | +| App-side unit tests for `NordicBleTransport` / `DfuSession` / `MetaBootProbe` | JVM coverage of the Android-only wrappers | Covered by the instrumented hardware suites and manual firmware-update passes | + +--- + +## License + +See [`LICENSE.md`](LICENSE.md). Copyright MbientLab Inc. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3370ef4..504b655 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,8 +8,7 @@ plugins { // Jetpack Compose MetaWear app: scanning, live streaming with ring-buffer // decimation, on-device logging + download, session history with CSV export, -// LED/haptic controls, device settings, firmware updates, and a hardware-free -// demo mode. +// LED/haptic controls, device settings, and firmware updates. android { namespace = "com.mbientlab.metawear.app" compileSdk = 36 @@ -52,13 +51,15 @@ dependencies { implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.activity.compose) implementation(libs.androidx.navigation.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.core.ktx) // JVM unit tests: RingBuffer, Channel decimation, CSV export, filenames, - // bandwidth advisor, demo-transport round trips, group-capture walks. + // bandwidth advisor, group-capture walks, and end-to-end walks through the + // real MetaWearDevice against DemoBleTransport (a test-only emulator). testImplementation(libs.junit.jupiter) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.turbine) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a24e274..f83886b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,10 +19,14 @@ android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" /> + + - + @@ -30,6 +34,8 @@ diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/AppContainer.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/AppContainer.kt index a3a978d..8f6f0a9 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/AppContainer.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/AppContainer.kt @@ -6,7 +6,6 @@ import com.mbientlab.metawear.MetaWearDevice import com.mbientlab.metawear.MetaWearScanner import com.mbientlab.metawear.app.data.LogSessionRegistry import com.mbientlab.metawear.app.data.RememberedDeviceStore -import com.mbientlab.metawear.app.demo.DemoBleTransport import com.mbientlab.metawear.app.vm.GroupCaptureCoordinator import com.mbientlab.metawear.core.AndroidMetaWear import com.mbientlab.metawear.persistence.PersistenceDatabase @@ -25,7 +24,7 @@ class AppContainer(context: Context) { private val appContext = context.applicationContext - /** Process-lifetime host for SDK routers, scan collection, demo emitters. */ + /** Process-lifetime host for SDK routers and scan collection. */ val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) /** The app-wide scanner (one per process, per SDK guidance). */ @@ -52,38 +51,13 @@ class AppContainer(context: Context) { /** Identifier of the device the detail screens operate on. */ val activeDeviceId = MutableStateFlow(null) - /** Demo-mode toggle: surfaces the simulated device in the scan list. */ - val demoModeEnabled = MutableStateFlow(false) - - /** - * Simulated MetaMotion S fleet (see [DemoBleTransport]) — three boards - * with distinct identities so multi-board flows (group logging, per-board - * attribution) are exercisable without hardware. Lazily created, reused - * across connect/disconnect cycles, never persisted as remembered. - */ - val demoFleet: List by lazy { - (0 until 3).map { index -> - val identity = DemoBleTransport.Identity.board(index) - MetaWearDevice(identity.identifier, DemoBleTransport(appScope, identity), appScope) - } - } - - /** The legacy single demo board (fleet index 0). */ - val demoDevice: MetaWearDevice get() = demoFleet[0] - - /** Display name for demo fleet board [index] ("Simulated MetaWear", "… 2", …). */ - fun demoName(index: Int): String = - if (index == 0) DemoBleTransport.DEVICE_NAME else "${DemoBleTransport.DEVICE_NAME} ${index + 1}" - /** Fleet-wide group logging coordinator (survives navigation). */ val groupCapture: GroupCaptureCoordinator by lazy { GroupCaptureCoordinator(persistence, logSessions) } - /** Resolve an identifier to a device (demo or scanner-vended). */ - fun device(identifier: String): MetaWearDevice = - demoFleet.firstOrNull { it.identifier == identifier } - ?: scanner.deviceForKnownIdentifier(identifier) + /** Resolve an identifier to its scanner-vended device. */ + fun device(identifier: String): MetaWearDevice = scanner.deviceForKnownIdentifier(identifier) /** * Best display name for a board right now: live advertised name, then the @@ -91,12 +65,9 @@ class AppContainer(context: Context) { * time — names are unrecoverable later (boards go off air; the advertised * cache is per-host). */ - fun displayNameFor(identifier: String): String? { - val demoIndex = demoFleet.indexOfFirst { it.identifier == identifier } - if (demoIndex >= 0) return demoName(demoIndex) - return scanner.advertisedNames.value[identifier]?.ifEmpty { null } + fun displayNameFor(identifier: String): String? = + scanner.advertisedNames.value[identifier]?.ifEmpty { null } ?: remembered.devices.value.firstOrNull { it.mac == identifier }?.name - } /** The device the detail screens operate on. Null before any selection. */ fun activeDevice(): MetaWearDevice? = activeDeviceId.value?.let { device(it) } diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/export/CsvShare.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/export/CsvShare.kt index 1a6a533..c29dcfd 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/export/CsvShare.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/export/CsvShare.kt @@ -2,6 +2,7 @@ package com.mbientlab.metawear.app.export import android.content.Context import android.content.Intent +import android.net.Uri import androidx.core.content.FileProvider import java.io.File @@ -25,4 +26,28 @@ object CsvShare { } context.startActivity(Intent.createChooser(send, "Export $filename")) } + + /** + * Share several CSVs in one go (`ACTION_SEND_MULTIPLE`) — one chooser for + * a whole live-stream buffer instead of one per channel. A single file + * falls back to [share]. + */ + fun shareAll(context: Context, files: List>) { + if (files.isEmpty()) return + if (files.size == 1) return share(context, files[0].first, files[0].second) + val dir = File(context.cacheDir, "exports").apply { mkdirs() } + val uris = ArrayList() + for ((filename, csv) in files) { + val file = File(dir, filename) + file.writeText(csv) + uris += FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + } + val send = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + type = "text/csv" + putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris) + putExtra(Intent.EXTRA_SUBJECT, "MetaWear export (${files.size} files)") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(send, "Export ${files.size} CSV files")) + } } diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/AppNavHost.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/AppNavHost.kt index 8c26756..6bcb185 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/AppNavHost.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/AppNavHost.kt @@ -5,9 +5,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument import com.mbientlab.metawear.app.ui.controls.ControlsScreen import com.mbientlab.metawear.app.ui.device.DeviceDetailScreen import com.mbientlab.metawear.app.ui.device.DeviceInfoScreen @@ -15,14 +17,16 @@ import com.mbientlab.metawear.app.ui.firmware.FirmwareScreen import com.mbientlab.metawear.app.ui.logging.GroupLoggingScreen import com.mbientlab.metawear.app.ui.logging.LoggingScreen import com.mbientlab.metawear.app.ui.scan.ScanScreen +import com.mbientlab.metawear.app.ui.sessions.SessionDetailScreen import com.mbientlab.metawear.app.ui.sessions.SessionsScreen import com.mbientlab.metawear.app.ui.settings.SettingsScreen import com.mbientlab.metawear.app.ui.stream.LiveStreamScreen -/** Navigation graph: scan → device hub → feature screens. */ +/** Navigation graph: scan → device hub → feature screens (+ session history/detail). */ @Composable fun AppNavHost() { val navController = rememberNavController() + val back: () -> Unit = { navController.popBackStack() } NavHost( navController = navController, @@ -35,23 +39,38 @@ fun AppNavHost() { ScanScreen( onDeviceSelected = { navController.navigate("device") }, onGroupLogging = { navController.navigate("group") }, + onSessionHistory = { navController.navigate("sessions") }, ) } - composable("group") { GroupLoggingScreen() } + composable("group") { + GroupLoggingScreen(onBack = back, onSessionHistory = { navController.navigate("sessions") }) + } composable("device") { DeviceDetailScreen( onNavigate = { route -> navController.navigate(route) }, onDisconnected = { navController.popBackStack("scan", inclusive = false) }, + onBack = back, ) } - composable("info") { DeviceInfoScreen() } - composable("stream") { LiveStreamScreen() } - composable("logging") { LoggingScreen() } - composable("sessions") { SessionsScreen() } - composable("controls") { ControlsScreen() } + composable("info") { DeviceInfoScreen(onBack = back) } + composable("stream") { LiveStreamScreen(onBack = back) } + composable("logging") { LoggingScreen(onBack = back) } + composable("sessions") { + SessionsScreen(onBack = back, onOpenSession = { id -> navController.navigate("session/$id") }) + } + composable( + route = "session/{id}", + arguments = listOf(navArgument("id") { type = NavType.StringType }), + ) { entry -> + SessionDetailScreen(sessionId = entry.arguments?.getString("id").orEmpty(), onBack = back) + } + composable("controls") { ControlsScreen(onBack = back) } composable("settings") { - SettingsScreen(onFactoryReset = { navController.popBackStack("device", inclusive = false) }) + SettingsScreen( + onFactoryReset = { navController.popBackStack("device", inclusive = false) }, + onBack = back, + ) } - composable("firmware") { FirmwareScreen() } + composable("firmware") { FirmwareScreen(onBack = back) } } } diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/Components.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/Components.kt index 64b297c..da7fa41 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/Components.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/Components.kt @@ -1,125 +1,243 @@ package com.mbientlab.metawear.app.ui.components -import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.mbientlab.metawear.DeviceState -import com.mbientlab.metawear.app.ui.theme.GlassAccent -import com.mbientlab.metawear.app.ui.theme.GlassError -import com.mbientlab.metawear.app.ui.theme.GlassGood -import com.mbientlab.metawear.app.ui.theme.GlassSurfaceHigh -import com.mbientlab.metawear.app.ui.theme.GlassTextDim -import com.mbientlab.metawear.app.ui.theme.GlassWarn +import com.mbientlab.metawear.app.ui.theme.forText -/** Translucent rounded card — the app's "glass" surface treatment. */ +/** Card shape used across the app — a touch rounder than the Material default. */ +val BrandCardShape = RoundedCornerShape(20.dp) + +/** + * The app's standard card: a filled Material card on the low surface tone, + * with a Column body. Pass [onClick] to make the whole card tappable and + * [contentPadding] = 0 when the body is a stack of [ListItem] rows. + */ @Composable -fun GlassCard( +fun BrandCard( modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + contentPadding: PaddingValues = PaddingValues(16.dp), + containerColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, + verticalSpacing: Dp = 8.dp, content: @Composable ColumnScope.() -> Unit, ) { - ElevatedCard( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), - ), - ) { + val colors = CardDefaults.cardColors(containerColor = containerColor) + val body: @Composable ColumnScope.() -> Unit = { Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(contentPadding), + verticalArrangement = Arrangement.spacedBy(verticalSpacing), content = content, ) } + if (onClick != null) { + Card( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + shape = BrandCardShape, + colors = colors, + content = body, + ) + } else { + Card( + modifier = modifier.fillMaxWidth(), + shape = BrandCardShape, + colors = colors, + content = body, + ) + } } +/** + * A card whose body is a list of rows ([ListItem]s separated by + * [androidx.compose.material3.HorizontalDivider]) — the grouped-settings idiom. + */ @Composable -fun SectionHeader(title: String) { - Text( - title.uppercase(), - style = MaterialTheme.typography.labelMedium, - color = GlassTextDim, - modifier = Modifier.padding(top = 8.dp), +fun GroupCard( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + BrandCard( + modifier = modifier, + contentPadding = PaddingValues(0.dp), + verticalSpacing = 0.dp, + content = content, ) } -/** Label on the left, value on the right — the settings-list staple. */ +/** List subheader: sits above a card group. */ @Composable -fun LabeledValue(label: String, value: String) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(label, color = GlassTextDim, style = MaterialTheme.typography.bodyMedium) - Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) - } +fun SectionHeader(title: String, color: Color = MaterialTheme.colorScheme.secondary) { + Text( + title, + style = MaterialTheme.typography.titleSmall, + color = color, + modifier = Modifier.padding(start = 4.dp, top = 8.dp, end = 4.dp), + ) } -/** Colored capsule reporting the device connection state. */ +/** Explanatory footer text below a card group. */ @Composable -fun StateBadge(state: DeviceState) { - val (label, color) = when (state) { - DeviceState.Disconnected -> "Disconnected" to GlassTextDim - DeviceState.Connecting -> "Connecting…" to GlassWarn - DeviceState.Idle -> "Connected" to GlassGood - DeviceState.Streaming -> "Streaming" to GlassAccent - DeviceState.Logging -> "Logging" to GlassAccent - is DeviceState.Downloading -> "Downloading ${(state.progress * 100).toInt()}%" to GlassAccent - } +fun SectionFooter(text: String, color: Color = MaterialTheme.colorScheme.onSurfaceVariant) { Text( - label, - style = MaterialTheme.typography.labelMedium, + text, + style = MaterialTheme.typography.bodySmall, color = color, - modifier = Modifier - .background(GlassSurfaceHigh, RoundedCornerShape(50)) - .padding(horizontal = 10.dp, vertical = 4.dp), + modifier = Modifier.padding(horizontal = 4.dp), ) } -/** Signal-strength pill: rough RSSI to quality words + color. */ +/** Transparent list-item colors so rows sit on the enclosing card. */ @Composable -fun RssiPill(rssi: Int?) { - val (label, color) = when { - rssi == null -> "—" to GlassTextDim - rssi >= -60 -> "$rssi dBm" to GlassGood - rssi >= -80 -> "$rssi dBm" to GlassWarn - else -> "$rssi dBm" to GlassError - } - Text(label, style = MaterialTheme.typography.labelMedium, color = color) -} +fun cardListItemColors() = ListItemDefaults.colors(containerColor = Color.Transparent) -/** Inline error banner with dismissal. */ +/** Label on the left, value on the right — the settings-list staple. */ @Composable -fun ErrorBanner(message: String?, onDismiss: () -> Unit) { - if (message == null) return - GlassCard { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { +fun LabeledValue(label: String, value: String, monospace: Boolean = false) { + ListItem( + headlineContent = { Text(label) }, + trailingContent = { Text( - message, - color = GlassError, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.weight(1f), + value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = if (monospace) FontFamily.Monospace else null, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) - TextButton(onClick = onDismiss) { Text("Dismiss") } - } + }, + colors = cardListItemColors(), + ) +} + +/** Tappable row with a leading icon — for actions inside a [GroupCard]. */ +@Composable +fun ActionRow( + title: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, + /** Emphasis color (e.g. danger for destructive rows); null = brand icon, plain title. */ + tint: Color? = null, + supporting: String? = null, + enabled: Boolean = true, +) { + val disabledColor = MaterialTheme.colorScheme.onSurfaceVariant + val titleColor = when { + !enabled -> disabledColor + tint != null -> tint.forText() + else -> MaterialTheme.colorScheme.onSurface + } + val iconColor = when { + !enabled -> disabledColor + else -> tint ?: MaterialTheme.colorScheme.primary + } + ListItem( + headlineContent = { Text(title, color = titleColor) }, + supportingContent = supporting?.let { { Text(it) } }, + leadingContent = { Icon(icon, contentDescription = null, tint = iconColor) }, + colors = cardListItemColors(), + modifier = modifier.then( + if (enabled) Modifier.clickable(onClick = onClick) else Modifier, + ), + ) +} + +/** A transient message for the scaffold's snackbar; [onShown] clears the source state. */ +data class Notice(val message: String, val onShown: () -> Unit) + +/** + * Screen frame: top app bar (with optional back navigation and actions) and + * a snackbar host that surfaces the screen's latest error/status [notice]. + * The container is transparent so screens can paint their own background. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppScaffold( + title: String, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, + notice: Notice? = null, + /** Title/icon color for the top bar when the screen paints its own chrome (e.g. white on orange). */ + topBarContentColor: Color? = null, + containerColor: Color = MaterialTheme.colorScheme.background, + actions: @Composable RowScope.() -> Unit = {}, + content: @Composable (PaddingValues) -> Unit, +) { + val snackbarHost = remember { SnackbarHostState() } + LaunchedEffect(notice?.message) { + val current = notice ?: return@LaunchedEffect + snackbarHost.showSnackbar(current.message, duration = SnackbarDuration.Long) + current.onShown() } + Scaffold( + modifier = modifier, + containerColor = containerColor, + snackbarHost = { SnackbarHost(snackbarHost) }, + topBar = { + TopAppBar( + title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = { + if (onBack != null) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + }, + actions = actions, + colors = if (topBarContentColor != null) { + TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, + titleContentColor = topBarContentColor, + actionIconContentColor = topBarContentColor, + navigationIconContentColor = topBarContentColor, + ) + } else { + // Opaque at rest. A transparent container only works with + // a scrollBehavior driving the scrolled color swap; without + // one the bar stays see-through and list content scrolls + // up underneath the title. + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, + ) + }, + ) + }, + content = content, + ) } diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/Formatters.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/Formatters.kt new file mode 100644 index 0000000..a41fd9c --- /dev/null +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/Formatters.kt @@ -0,0 +1,50 @@ +package com.mbientlab.metawear.app.ui.components + +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale +import kotlinx.datetime.Instant + +private val dateTimeFormatter: DateTimeFormatter = + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT) +private val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss") +private val clockFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") + +private fun Instant.zoned() = java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(ZoneId.systemDefault()) + +/** "Aug 16, 2026, 7:15 PM" (locale-dependent). */ +fun Instant.formatDateTime(): String = dateTimeFormatter.format(zoned()) + +/** "19:15:03" */ +fun Instant.formatTime(): String = timeFormatter.format(zoned()) + +/** "19:15" */ +fun Instant.formatClock(): String = clockFormatter.format(zoned()) + +/** "mm:ss" under an hour, "h:mm:ss" beyond. */ +fun formatDuration(totalSeconds: Int): String { + val s = totalSeconds.coerceAtLeast(0) + val hours = s / 3600 + val minutes = (s % 3600) / 60 + val seconds = s % 60 + return if (hours > 0) { + String.format(Locale.US, "%d:%02d:%02d", hours, minutes, seconds) + } else { + String.format(Locale.US, "%d:%02d", minutes, seconds) + } +} + +/** Fixed-precision value by unit: 2 digits for fine units, 1 for moderate, 0 for coarse. */ +fun formatMeasurement(value: Float, unit: String): String { + val digits = when (unit) { + "g", "", "ratio" -> 2 + "°C", "%", "µT", "lux", "m" -> 1 + else -> 0 + } + return String.format(Locale.US, "%.${digits}f", value) +} + +/** "50 Hz" for whole rates, "12.5 Hz" otherwise. */ +fun formatHz(hz: Double): String = + if (hz == hz.toLong().toDouble()) "${hz.toLong()} Hz" else String.format(Locale.US, "%.1f Hz", hz) diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/FusionCalibrationBadge.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/FusionCalibrationBadge.kt index 6f8ee85..c0bc152 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/FusionCalibrationBadge.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/FusionCalibrationBadge.kt @@ -1,55 +1,61 @@ package com.mbientlab.metawear.app.ui.components -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CenterFocusStrong +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.mbientlab.metawear.app.core.CalibrationReadiness -import com.mbientlab.metawear.app.ui.theme.GlassAccent -import com.mbientlab.metawear.app.ui.theme.GlassError -import com.mbientlab.metawear.app.ui.theme.GlassGood -import com.mbientlab.metawear.app.ui.theme.GlassTextDim -import com.mbientlab.metawear.app.ui.theme.GlassWarn +import com.mbientlab.metawear.app.ui.theme.Palette import com.mbientlab.metawear.sensor.SensorFusionCalibration /** - * Live fusion-calibration badge: three A/G/M accuracy chips plus per-sensor - * coaching. The bar is MEDIUM — a demoted-indoors magnetometer must not read - * as a problem. + * Live fusion-calibration card: readiness headline, three A/G/M accuracy + * chips, and per-sensor coaching for whichever sensor is still below the + * bar. The bar is MEDIUM — a demoted-indoors magnetometer must not read as + * a problem. */ @Composable fun FusionCalibrationBadge(calibration: SensorFusionCalibration) { val state = CalibrationReadiness.state(calibration) + val (icon, tint) = when (state) { + CalibrationReadiness.State.CALIBRATING -> Icons.Filled.CenterFocusStrong to Palette.warning + CalibrationReadiness.State.READY -> Icons.Filled.CheckCircle to Palette.info + CalibrationReadiness.State.FULLY_CALIBRATED -> Icons.Filled.Verified to Palette.success + } - GlassCard { + BrandCard(verticalSpacing = 6.dp) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { + Icon(icon, contentDescription = null, tint = tint) Text( CalibrationReadiness.title(state), style = MaterialTheme.typography.titleSmall, - color = when (state) { - CalibrationReadiness.State.FULLY_CALIBRATED -> GlassGood - CalibrationReadiness.State.READY -> GlassAccent - CalibrationReadiness.State.CALIBRATING -> GlassWarn - }, + modifier = Modifier.weight(1f), ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - LevelChip("A", calibration.accelerometer) - LevelChip("G", calibration.gyroscope) - LevelChip("M", calibration.magnetometer) - } + LevelChip("A", calibration.accelerometer) + LevelChip("G", calibration.gyroscope) + LevelChip("M", calibration.magnetometer) } when (state) { @@ -57,35 +63,50 @@ fun FusionCalibrationBadge(calibration: SensorFusionCalibration) { CalibrationReadiness.State.READY -> Text( CalibrationReadiness.READY_FOOTNOTE, style = MaterialTheme.typography.bodySmall, - color = GlassTextDim, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) CalibrationReadiness.State.CALIBRATING -> { CalibrationReadiness.coaching(calibration).forEach { coaching -> - Text( - "${coaching.sensor}: ${coaching.advice}", - style = MaterialTheme.typography.bodySmall, - color = GlassTextDim, - ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + coaching.sensor, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(14.dp), + textAlign = TextAlign.Center, + ) + Text( + coaching.advice, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + } } } } } } +/** One accuracy chip: white letter on a level-colored capsule. */ @Composable private fun LevelChip(label: String, level: Int) { val color = when { - level <= 0 -> GlassError - level == 1 -> GlassWarn - level == 2 -> GlassAccent - else -> GlassGood + level <= 0 -> Palette.danger + level == 1 -> Palette.warning + level == 2 -> Palette.info + else -> Palette.success + } + Surface(shape = CircleShape, color = color, contentColor = Color.White) { + Text( + label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier + .size(width = 26.dp, height = 22.dp) + .padding(top = 2.dp), + ) } - Text( - "$label $level", - style = MaterialTheme.typography.labelMedium, - color = Color(0xFF06222E), - modifier = Modifier - .background(color, RoundedCornerShape(50)) - .padding(horizontal = 8.dp, vertical = 2.dp), - ) } diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/LineChart.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/LineChart.kt index 37e149d..1a49a84 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/LineChart.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/LineChart.kt @@ -3,15 +3,16 @@ package com.mbientlab.metawear.app.ui.components import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.mbientlab.metawear.app.core.AnyChartSample -import com.mbientlab.metawear.app.ui.theme.GlassSurfaceHigh /** * Self-contained Canvas line chart for the decimated live buffer. One path @@ -27,13 +28,14 @@ fun LineChart( modifier: Modifier = Modifier, height: Dp = 160.dp, ) { + val gridColor = MaterialTheme.colorScheme.outlineVariant Canvas( modifier = modifier .fillMaxWidth() .height(height), ) { // Frame + midline. - drawRect(GlassSurfaceHigh, style = Stroke(width = 1.dp.toPx())) + drawRect(gridColor, style = Stroke(width = 1.dp.toPx())) if (samples.size < 2) return@Canvas @@ -67,9 +69,9 @@ fun LineChart( val midValue = if (minY <= 0f && 0f <= maxY) 0f else (minY + maxY) / 2f val midY = size.height * (1f - (midValue - minY) / ySpan) drawLine( - GlassSurfaceHigh, - start = androidx.compose.ui.geometry.Offset(0f, midY), - end = androidx.compose.ui.geometry.Offset(size.width, midY), + gridColor, + start = Offset(0f, midY), + end = Offset(size.width, midY), strokeWidth = 1.dp.toPx(), ) diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/QuaternionCubeView.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/QuaternionCubeView.kt index de4da14..d443b45 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/QuaternionCubeView.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/QuaternionCubeView.kt @@ -6,11 +6,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.mbientlab.metawear.app.core.QuaternionCube -import com.mbientlab.metawear.app.ui.theme.GlassAccent -import com.mbientlab.metawear.app.ui.theme.GlassSurfaceHigh +import com.mbientlab.metawear.app.ui.theme.Palette /** * Dependency-free 3D orientation cube: rotates a wireframe cube by the live @@ -27,6 +28,7 @@ fun QuaternionCubeView( modifier: Modifier = Modifier, height: Dp = 180.dp, ) { + val frameColor = MaterialTheme.colorScheme.outlineVariant Canvas( modifier = modifier .fillMaxWidth() @@ -46,7 +48,7 @@ fun QuaternionCubeView( val depth = ((vertices[a].z + vertices[b].z) / 2f / 1.75f).coerceIn(-1f, 1f) val t = (depth + 1f) / 2f drawLine( - color = GlassAccent.copy(alpha = 0.25f + 0.75f * t), + color = Palette.accent.copy(alpha = 0.25f + 0.75f * t), start = projected[a], end = projected[b], strokeWidth = (1f + 2f * t).dp.toPx(), @@ -55,9 +57,9 @@ fun QuaternionCubeView( // Vertex dots for the front-most corners. vertices.forEachIndexed { index, v -> if (v.z > 0f) { - drawCircle(GlassAccent, radius = 3.dp.toPx(), center = projected[index]) + drawCircle(Palette.accent, radius = 3.dp.toPx(), center = projected[index]) } } - drawRect(GlassSurfaceHigh, style = androidx.compose.ui.graphics.drawscope.Stroke(1.dp.toPx())) + drawRect(frameColor, style = Stroke(1.dp.toPx())) } } diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/SensorChartCard.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/SensorChartCard.kt new file mode 100644 index 0000000..c61da48 --- /dev/null +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/SensorChartCard.kt @@ -0,0 +1,184 @@ +package com.mbientlab.metawear.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.mbientlab.metawear.app.core.AnyChartSample +import com.mbientlab.metawear.app.core.AxisStyle +import com.mbientlab.metawear.app.ui.theme.ChannelColors +import com.mbientlab.metawear.app.ui.theme.FourChannelColors +import com.mbientlab.metawear.app.ui.theme.Palette +import java.text.NumberFormat +import kotlinx.coroutines.delay +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +/** Chart line colors for a given axis style (four-channel outputs lead with purple). */ +fun AxisStyle.chartColors(): List = if (chartChannels == 4) FourChannelColors else ChannelColors + +/** + * Compact stats strip above the chart cards: elapsed session time (ticking + * once a second) and the running total sample count across all channels. + */ +@Composable +fun SessionStatsBar(startedAt: Instant, totalSamples: Int) { + var elapsedSeconds by remember(startedAt) { mutableIntStateOf(0) } + LaunchedEffect(startedAt) { + while (true) { + elapsedSeconds = ((Clock.System.now() - startedAt).inWholeSeconds).toInt() + delay(1_000) + } + } + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Stat(Icons.Outlined.Schedule, formatDuration(elapsedSeconds)) + VerticalDivider(modifier = Modifier.height(14.dp)) + Stat(Icons.Filled.Tag, "${NumberFormat.getIntegerInstance().format(totalSamples)} samples") + } + } +} + +@Composable +private fun Stat(icon: ImageVector, label: String) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(icon, contentDescription = null, tint = Palette.accent, modifier = Modifier.size(16.dp)) + Text(label, style = MaterialTheme.typography.labelMedium, fontFamily = FontFamily.Monospace) + } +} + +/** + * Row of small chips, one per channel, showing the latest value in the same + * color as the chart line — precise numbers without eyeballing the trace. + */ +@Composable +fun SensorReadoutChips(style: AxisStyle, latest: AnyChartSample?, colors: List = style.chartColors()) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + style.labels.take(style.chartChannels).forEachIndexed { index, label -> + val color = colors.getOrElse(index) { colors.last() } + val value = latest?.channel(index) + Surface(shape = CircleShape, color = color.copy(alpha = 0.12f)) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(6.dp).background(color, CircleShape)) + Text( + label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + value?.let { formatMeasurement(it, style.unit) } ?: "—", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = if (value != null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline, + ) + } + } + } + } +} + +/** + * One sensor's card: icon + title with the effective rate on the right, the + * per-channel readout chips, then the line chart (or a "waiting" placeholder + * until the first samples land). + */ +@Composable +fun SensorChartCard( + title: String, + icon: ImageVector, + samples: List, + latest: AnyChartSample?, + style: AxisStyle, + rateLabel: String? = null, + trailing: (@Composable () -> Unit)? = null, + footer: (@Composable () -> Unit)? = null, +) { + BrandCard(verticalSpacing = 8.dp) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = Palette.accent, modifier = Modifier.size(20.dp)) + Text(title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f)) + if (rateLabel != null) { + Text( + rateLabel, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + trailing?.invoke() + } + SensorReadoutChips(style = style, latest = latest) + if (samples.size < 2) { + Column( + modifier = Modifier + .fillMaxWidth() + .height(160.dp), + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + Text( + "Waiting for first sample…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LineChart( + samples = samples, + channelCount = style.chartChannels, + colors = style.chartColors(), + yRange = style.yRange, + modifier = Modifier.padding(top = 4.dp), + height = 180.dp, + ) + } + footer?.invoke() + } +} diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/SensorIcons.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/SensorIcons.kt new file mode 100644 index 0000000..3f6beb8 --- /dev/null +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/SensorIcons.kt @@ -0,0 +1,39 @@ +package com.mbientlab.metawear.app.ui.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cached +import androidx.compose.material.icons.filled.DeviceThermostat +import androidx.compose.material.icons.filled.Explore +import androidx.compose.material.icons.filled.OpenWith +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.Terrain +import androidx.compose.material.icons.filled.ViewInAr +import androidx.compose.material.icons.filled.WaterDrop +import androidx.compose.material.icons.filled.WbSunny +import androidx.compose.ui.graphics.vector.ImageVector +import com.mbientlab.metawear.app.core.SensorKey + +/** One Material icon per sensor family — shared by pickers, chart cards, and history rows. */ +val SensorKey.icon: ImageVector + get() = when (this) { + SensorKey.ACCELEROMETER -> Icons.Filled.OpenWith + SensorKey.GYROSCOPE -> Icons.Filled.Cached + SensorKey.MAGNETOMETER -> Icons.Filled.Explore + SensorKey.TEMPERATURE -> Icons.Filled.DeviceThermostat + SensorKey.HUMIDITY -> Icons.Filled.WaterDrop + SensorKey.PRESSURE, SensorKey.PRESSURE_STREAMED -> Icons.Filled.Speed + SensorKey.ALTITUDE -> Icons.Filled.Terrain + SensorKey.AMBIENT_LIGHT -> Icons.Filled.WbSunny + else -> Icons.Filled.ViewInAr // sensor-fusion outputs + } + +/** + * Icon for a saved session, recovered from its capture-time label + * ("Gyroscope · ±2000 dps · 25 Hz" → gyroscope). Falls back to a generic + * record glyph for labels that don't map (foreign recoveries, legacy rows). + */ +fun sensorIconForLabel(label: String?): ImageVector { + val head = label?.substringBefore(" · ") ?: return Icons.Filled.RadioButtonChecked + return SensorKey.entries.firstOrNull { it.title == head }?.icon ?: Icons.Filled.RadioButtonChecked +} diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/StatusChips.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/StatusChips.kt new file mode 100644 index 0000000..5346a49 --- /dev/null +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/components/StatusChips.kt @@ -0,0 +1,247 @@ +package com.mbientlab.metawear.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.TrendingUp +import androidx.compose.material.icons.filled.Battery0Bar +import androidx.compose.material.icons.filled.Battery2Bar +import androidx.compose.material.icons.filled.Battery3Bar +import androidx.compose.material.icons.filled.Battery5Bar +import androidx.compose.material.icons.filled.BatteryFull +import androidx.compose.material.icons.filled.BluetoothDisabled +import androidx.compose.material.icons.filled.BluetoothSearching +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.FiberManualRecord +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Circle +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.mbientlab.metawear.DeviceState +import com.mbientlab.metawear.app.core.BandwidthAdvisor +import com.mbientlab.metawear.app.ui.theme.Palette +import com.mbientlab.metawear.app.ui.theme.forText +import com.mbientlab.metawear.model.BatteryState + +/** Compact tonal status indicator: tinted capsule with an icon and a short label. */ +@Composable +fun StatusChip( + text: String, + tint: Color, + modifier: Modifier = Modifier, + icon: ImageVector? = null, + leading: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, +) { + Surface( + modifier = modifier, + shape = CircleShape, + color = tint.copy(alpha = 0.15f), + contentColor = tint.forText(), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (leading != null) { + leading() + } else if (icon != null) { + Icon(icon, contentDescription = null, modifier = Modifier.size(14.dp)) + } + Text(text, style = MaterialTheme.typography.labelMedium, maxLines = 1) + trailing?.invoke() + } + } +} + +/** How a device on the scan screen relates to this phone right now. */ +sealed interface DeviceConnectionStatus { + data class Connected(val rssi: Int?) : DeviceConnectionStatus + data object Connecting : DeviceConnectionStatus + data class Available(val rssi: Int?) : DeviceConnectionStatus + data object Offline : DeviceConnectionStatus +} + +/** + * Scan-row status: connected (green, with bars), connecting (gold + spinner), + * available (blue bars + dBm), or offline (gray). + */ +@Composable +fun DeviceStatusChip(status: DeviceConnectionStatus) { + when (status) { + is DeviceConnectionStatus.Connected -> StatusChip( + text = "Connected", + tint = Palette.success, + icon = Icons.Filled.Circle, + trailing = status.rssi?.let { { RssiBars(dBm = it, tint = Palette.success) } }, + ) + DeviceConnectionStatus.Connecting -> StatusChip( + text = "Connecting…", + tint = Palette.warning, + leading = { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + color = Palette.warning, + ) + }, + ) + is DeviceConnectionStatus.Available -> StatusChip( + text = status.rssi?.let { "$it dBm" } ?: "Available", + tint = Palette.info, + leading = { RssiBars(dBm = status.rssi) }, + ) + DeviceConnectionStatus.Offline -> StatusChip( + text = "Offline", + tint = Palette.neutral, + icon = Icons.Outlined.Circle, + ) + } +} + +/** + * Four signal bars. Defaults to the info blue so the bars match the blue + * capsule they usually sit in — deliberately NOT the brand orange. + */ +@Composable +fun RssiBars(dBm: Int?, tint: Color = Palette.info) { + val bars = when { + dBm == null -> 0 + dBm < -90 -> 1 + dBm < -75 -> 2 + dBm < -60 -> 3 + else -> 4 + } + val off = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.Bottom, + ) { + repeat(4) { index -> + Box( + modifier = Modifier + .width(3.dp) + .height((6 + index * 3).dp) + .background(if (index < bars) tint else off, CircleShape), + ) + } + } +} + +/** Live link quality: bars + dBm in the info blue. */ +@Composable +fun RssiChip(dBm: Int) { + StatusChip(text = "$dBm dBm", tint = Palette.info, leading = { RssiBars(dBm) }) +} + +/** + * Connection/activity state chip. [isLogging] forces the red "Logging" look + * for a board that is recording autonomously while the SDK reports idle. + */ +@Composable +fun StateChip(state: DeviceState, isLogging: Boolean = false) { + val (label, icon, tint) = when { + isLogging -> Triple("Logging", Icons.Filled.FiberManualRecord, Palette.danger) + state == DeviceState.Disconnected -> Triple("Disconnected", Icons.Filled.BluetoothDisabled, Palette.danger) + state == DeviceState.Connecting -> Triple("Connecting…", Icons.Filled.BluetoothSearching, Palette.warning) + state == DeviceState.Idle -> Triple("Idle", Icons.Outlined.CheckCircle, Palette.warning) + state == DeviceState.Streaming -> Triple("Streaming", Icons.AutoMirrored.Filled.TrendingUp, Palette.info) + state == DeviceState.Logging -> Triple("Logging", Icons.Filled.FiberManualRecord, Palette.danger) + state is DeviceState.Downloading -> + Triple("Downloading ${(state.progress * 100).toInt()}%", Icons.Filled.Download, Palette.accent) + else -> Triple("—", Icons.Outlined.Circle, Palette.neutral) + } + StatusChip(text = label, tint = tint, icon = icon) +} + +/** Battery charge chip, colored by level (coral < 15 %, gold < 35 %, green above). */ +@Composable +fun BatteryChip(battery: BatteryState?) { + val charge = battery?.charge + val icon = when { + charge == null || charge < 10 -> Icons.Filled.Battery0Bar + charge < 25 -> Icons.Filled.Battery2Bar + charge < 50 -> Icons.Filled.Battery3Bar + charge < 75 -> Icons.Filled.Battery5Bar + else -> Icons.Filled.BatteryFull + } + val tint = when { + charge == null -> Palette.neutral + charge < 15 -> Palette.danger + charge < 35 -> Palette.warning + else -> Palette.success + } + StatusChip(text = charge?.let { "$it%" } ?: "—", tint = tint, icon = icon) +} + +/** + * Small tonal pill for secondary facts (model name, firmware) in a header. + * [emphasized] switches to the primary-container tone (brand orange tint). + */ +@Composable +fun InfoPill(text: String, emphasized: Boolean = false, monospace: Boolean = false) { + Surface( + shape = CircleShape, + color = if (emphasized) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = if (emphasized) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + ) { + Text( + text, + style = MaterialTheme.typography.labelMedium, + fontFamily = if (monospace) FontFamily.Monospace else null, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + ) + } +} + +/** + * Bandwidth advisor: shown only when the combined sample rate exceeds the + * BLE ceiling, with a one-tap "halve all rates" remedy. + */ +@Composable +fun BandwidthCard(aggregateHz: Double, onHalve: () -> Unit) { + if (aggregateHz <= BandwidthAdvisor.BLE_SAFE_CEILING_HZ) return + BrandCard(containerColor = Palette.warning.copy(alpha = 0.15f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Filled.Warning, contentDescription = null, tint = Palette.warning) + Column(Modifier.weight(1f)) { + Text("Combined rate ${aggregateHz.toInt()} Hz", style = MaterialTheme.typography.titleSmall) + Text( + "Above BLE limit — samples may drop.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + FilledTonalButton(onClick = onHalve) { Text("Halve rates") } + } + } +} diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/controls/ControlsScreen.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/controls/ControlsScreen.kt index 95aa7fc..3787157 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/controls/ControlsScreen.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/controls/ControlsScreen.kt @@ -1,13 +1,26 @@ package com.mbientlab.metawear.app.ui.controls import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.Vibration +import androidx.compose.material.icons.filled.VolumeUp +import androidx.compose.material.icons.outlined.Lightbulb import androidx.compose.material3.Button import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Slider @@ -15,18 +28,23 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import com.mbientlab.metawear.app.ui.appViewModel -import com.mbientlab.metawear.app.ui.components.ErrorBanner -import com.mbientlab.metawear.app.ui.components.GlassCard +import com.mbientlab.metawear.app.ui.components.AppScaffold +import com.mbientlab.metawear.app.ui.components.BrandCard +import com.mbientlab.metawear.app.ui.components.Notice +import com.mbientlab.metawear.app.ui.components.SectionFooter import com.mbientlab.metawear.app.ui.components.SectionHeader -import com.mbientlab.metawear.app.ui.theme.GlassTextDim +import com.mbientlab.metawear.app.ui.theme.Palette import com.mbientlab.metawear.app.vm.ControlsViewModel import com.mbientlab.metawear.sensor.Led +/** LED (color, pattern, play/stop) and haptic (duty, pulse, motor/buzzer) controls. */ @Composable -fun ControlsScreen() { +fun ControlsScreen(onBack: () -> Unit) { val vm = appViewModel(::ControlsViewModel) val ledColor by vm.ledColor.collectAsState() val ledPattern by vm.ledPattern.collectAsState() @@ -34,80 +52,134 @@ fun ControlsScreen() { val motorPulseMs by vm.motorPulseMs.collectAsState() val lastError by vm.lastError.collectAsState() - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - item { Text("Controls", style = MaterialTheme.typography.headlineSmall) } - item { ErrorBanner(lastError) { vm.clearError() } } - - item { SectionHeader("LED") } - item { - GlassCard { - Text("Color", style = MaterialTheme.typography.labelMedium, color = GlassTextDim) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Led.Color.entries.forEach { color -> - FilterChip( - selected = ledColor == color, - onClick = { vm.setLedColor(color) }, - label = { Text(color.name.lowercase().replaceFirstChar { it.uppercase() }) }, - ) + AppScaffold( + title = "Controls", + onBack = onBack, + notice = lastError?.let { Notice(it) { vm.clearError() } }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = padding.calculateTopPadding() + 4.dp, + bottom = padding.calculateBottomPadding() + 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { SectionHeader("LED") } + item { + BrandCard(verticalSpacing = 10.dp) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.Lightbulb, contentDescription = null, tint = Palette.accent) + Text("Pattern playback", style = MaterialTheme.typography.titleSmall) } - } - Text("Pattern", style = MaterialTheme.typography.labelMedium, color = GlassTextDim) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - ControlsViewModel.PatternPreset.entries.forEach { preset -> - FilterChip( - selected = ledPattern == preset, - onClick = { vm.setLedPattern(preset) }, - label = { Text(preset.title) }, - ) + ChoiceRow("Color") { + Led.Color.entries.forEach { color -> + FilterChip( + selected = ledColor == color, + onClick = { vm.setLedColor(color) }, + label = { Text(color.name.lowercase().replaceFirstChar { it.uppercase() }) }, + ) + } } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button(onClick = { vm.playLed() }, modifier = Modifier.weight(1f)) { Text("Play") } - OutlinedButton(onClick = { vm.stopLedPlayback() }, modifier = Modifier.weight(1f)) { - Text("Stop") + ChoiceRow("Pattern") { + ControlsViewModel.PatternPreset.entries.forEach { preset -> + FilterChip( + selected = ledPattern == preset, + onClick = { vm.setLedPattern(preset) }, + label = { Text(preset.title) }, + ) + } + } + HorizontalDivider() + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button(onClick = { vm.playLed() }, modifier = Modifier.weight(1f)) { + Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Play") + } + OutlinedButton(onClick = { vm.stopLedPlayback() }, modifier = Modifier.weight(1f)) { + Icon(Icons.Filled.Stop, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Stop") + } } } } - } - item { SectionHeader("Haptic") } - item { - GlassCard { - Text("Strength $motorDuty%", style = MaterialTheme.typography.labelMedium, color = GlassTextDim) - Slider( - value = motorDuty.toFloat(), - onValueChange = { vm.setMotorDuty(it.toInt()) }, - valueRange = 0f..100f, - ) - Text("Pulse $motorPulseMs ms", style = MaterialTheme.typography.labelMedium, color = GlassTextDim) - Slider( - value = motorPulseMs.toFloat(), - onValueChange = { vm.setMotorPulseMs(it.toInt()) }, - valueRange = 50f..2000f, - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button(onClick = { vm.pulseMotor() }, modifier = Modifier.weight(1f)) { Text("Buzz Motor") } - OutlinedButton(onClick = { vm.pulseBuzzer() }, modifier = Modifier.weight(1f)) { - Text("Buzzer") + item { SectionHeader("Haptic") } + item { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + BrandCard(verticalSpacing = 4.dp) { + SliderRow( + label = "Duty cycle", + value = "$motorDuty%", + ) { + Slider( + value = motorDuty.toFloat(), + onValueChange = { vm.setMotorDuty(it.toInt()) }, + valueRange = 0f..100f, + steps = 9, + ) + } + SliderRow( + label = "Pulse width", + value = "$motorPulseMs ms", + ) { + Slider( + value = motorPulseMs.toFloat(), + onValueChange = { vm.setMotorPulseMs(it.toInt()) }, + valueRange = 50f..2000f, + ) + } + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button(onClick = { vm.pulseMotor() }, modifier = Modifier.weight(1f)) { + Icon(Icons.Filled.Vibration, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Motor") + } + OutlinedButton(onClick = { vm.pulseBuzzer() }, modifier = Modifier.weight(1f)) { + Icon(Icons.Filled.VolumeUp, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Buzzer") + } + } } + SectionFooter("MMR/MMRL boards carry a motor; MMC/MMS route this to the buzzer output.") } - Text( - "MMR/MMRL boards carry a motor; MMC/MMS route this to the buzzer output.", - style = MaterialTheme.typography.bodySmall, - color = GlassTextDim, - ) } } } } + +@Composable +private fun ChoiceRow(label: String, chips: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { chips() } + } +} + +@Composable +private fun SliderRow(label: String, value: String, slider: @Composable () -> Unit) { + Column { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Text( + value, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + slider() + } +} diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/device/DeviceDetailScreen.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/device/DeviceDetailScreen.kt index ee66d69..c1fe49b 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/device/DeviceDetailScreen.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/device/DeviceDetailScreen.kt @@ -1,151 +1,303 @@ package com.mbientlab.metawear.app.ui.device +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.TrendingUp +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Inbox +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Lightbulb +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.mbientlab.metawear.DeviceState +import com.mbientlab.metawear.app.data.LogSessionRecord +import com.mbientlab.metawear.app.ui.appContainer import com.mbientlab.metawear.app.ui.appViewModel -import com.mbientlab.metawear.app.ui.components.ErrorBanner -import com.mbientlab.metawear.app.ui.components.GlassCard -import com.mbientlab.metawear.app.ui.components.LabeledValue -import com.mbientlab.metawear.app.ui.components.SectionHeader -import com.mbientlab.metawear.app.ui.components.StateBadge -import com.mbientlab.metawear.app.ui.theme.GlassTextDim +import com.mbientlab.metawear.app.ui.components.AppScaffold +import com.mbientlab.metawear.app.ui.components.BatteryChip +import com.mbientlab.metawear.app.ui.components.BrandCard +import com.mbientlab.metawear.app.ui.components.BrandCardShape +import com.mbientlab.metawear.app.ui.components.InfoPill +import com.mbientlab.metawear.app.ui.components.Notice +import com.mbientlab.metawear.app.ui.components.StateChip +import com.mbientlab.metawear.app.ui.theme.Palette import com.mbientlab.metawear.app.vm.DeviceViewModel -/** One navigable feature row of the device hub. */ -data class FeatureDestination(val title: String, val subtitle: String, val route: String) +/** One activity tile of the device hub. */ +private data class ActivityTile(val title: String, val icon: ImageVector, val route: String) -private val features = listOf( - FeatureDestination("Live Stream", "Charts, xyz readout, effective Hz", "stream"), - FeatureDestination("Logging", "Record to flash, download sessions", "logging"), - FeatureDestination("Sessions", "History and CSV export", "sessions"), - FeatureDestination("Controls", "LED patterns and haptic pulses", "controls"), - FeatureDestination("Device Info", "Model, firmware, modules", "info"), - FeatureDestination("Settings", "Name, advertising, factory reset", "settings"), - FeatureDestination("Firmware", "Check and install updates", "firmware"), +private val activityTiles = listOf( + ActivityTile("Live Stream", Icons.AutoMirrored.Filled.TrendingUp, "stream"), + ActivityTile("Logging", Icons.Filled.RadioButtonChecked, "logging"), + ActivityTile("Controls", Icons.Filled.Tune, "controls"), + ActivityTile("Device Info", Icons.Outlined.Info, "info"), + ActivityTile("Settings", Icons.Outlined.Settings, "settings"), + ActivityTile("Session History", Icons.Filled.History, "sessions"), ) +/** + * Device hub: identity header (MAC, state, battery, model, firmware) above a + * grid of activity tiles. Identify/Disconnect live in the top bar. + */ @Composable -fun DeviceDetailScreen(onNavigate: (String) -> Unit, onDisconnected: () -> Unit) { +fun DeviceDetailScreen(onNavigate: (String) -> Unit, onDisconnected: () -> Unit, onBack: () -> Unit) { val vm = appViewModel(::DeviceViewModel) + val container = appContainer() val state by vm.state.collectAsState() val info by vm.deviceInfo.collectAsState() val battery by vm.battery.collectAsState() + val mac by vm.macAddress.collectAsState() val isConnecting by vm.isConnecting.collectAsState() val lastError by vm.lastError.collectAsState() val foreignLog by vm.foreignLog.collectAsState() val foreignStatus by vm.foreignStatus.collectAsState() + val records by container.logSessions.records.collectAsState() - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - item { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + val deviceId = vm.device?.identifier + val isDeviceLogging = records.any { + it.deviceId == deviceId && it.status == LogSessionRecord.Status.RUNNING + } + val notice = lastError?.let { Notice(it) { vm.clearError() } } + ?: foreignStatus?.let { Notice(it) { vm.clearForeignStatus() } } + + Box(Modifier.fillMaxSize()) { + AppScaffold( + title = vm.displayName, + onBack = onBack, + notice = notice, + actions = { + IconButton(onClick = { vm.identify() }, enabled = state != DeviceState.Disconnected) { + Icon(Icons.Outlined.Lightbulb, contentDescription = "Identify") + } + IconButton( + onClick = { + vm.disconnect() + onDisconnected() + }, + enabled = state != DeviceState.Disconnected, + ) { + Icon(Icons.Outlined.Cancel, contentDescription = "Disconnect", tint = Palette.danger) + } + }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = padding.calculateTopPadding() + 4.dp, + bottom = padding.calculateBottomPadding() + 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Column(Modifier.weight(1f)) { - Text(vm.displayName, style = MaterialTheme.typography.headlineSmall) - Text( - vm.device?.identifier ?: "", - style = MaterialTheme.typography.bodySmall, - color = GlassTextDim, - ) + item { + BrandCard(verticalSpacing = 12.dp) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + mac ?: deviceId ?: "—", + style = MaterialTheme.typography.titleSmall, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + StateChip(state = state, isLogging = isDeviceLogging) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + BatteryChip(battery) + info?.let { device -> + InfoPill(device.model.displayName, emphasized = true) + InfoPill("fw ${device.firmwareRevision}", monospace = true) + } + } + if (state == DeviceState.Disconnected && !isConnecting) { + OutlinedButton(onClick = { vm.reconnect() }) { Text("Reconnect") } + } + } } - if (isConnecting) CircularProgressIndicator(Modifier.padding(4.dp)) else StateBadge(state) - } - } - - item { ErrorBanner(lastError) { vm.clearError() } } - foreignLog?.let { orphan -> - item { - GlassCard { - Text("Log from another session", style = MaterialTheme.typography.titleSmall) - Text( - buildString { - append("This board carries a log this app didn't start") - if (orphan.entryCount > 0) append(" (${orphan.entryCount} entries)") - if (orphan.isActivelyLogging) append(" — and it is still recording") - append(". Recover it into session history, or discard it.") - }, - style = MaterialTheme.typography.bodySmall, - color = GlassTextDim, - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton(onClick = { vm.downloadForeignLog() }) { Text("Download") } - TextButton(onClick = { vm.discardForeignLog() }) { Text("Discard") } + foreignLog?.let { orphan -> + item { + BrandCard { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (orphan.isActivelyLogging) Icons.Filled.RadioButtonChecked else Icons.Filled.Inbox, + contentDescription = null, + tint = Palette.accent, + ) + Column(Modifier.weight(1f)) { + Text( + if (orphan.isActivelyLogging) "Logging in progress from another device" + else "Data on this board", + style = MaterialTheme.typography.titleSmall, + ) + Text( + when { + orphan.isActivelyLogging && orphan.entryCount > 0 -> + "${orphan.entryCount} entries so far" + orphan.isActivelyLogging -> + "Recording — first entries are still buffering to flash" + else -> "${orphan.entryCount} entries recorded" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Text( + "Downloading saves the recording to Session History" + + (if (orphan.isActivelyLogging) " and stops the logging." else ".") + + " Sensor types are reconstructed from the board's own logger metadata.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilledTonalButton(onClick = { vm.downloadForeignLog() }) { + Icon(Icons.Filled.Download, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(if (orphan.isActivelyLogging) "Stop & Download" else "Download") + } + TextButton( + onClick = { vm.discardForeignLog() }, + colors = ButtonDefaults.textButtonColors(contentColor = Palette.danger), + ) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Discard") + } + } + } } } - } - } - foreignStatus?.let { status -> - item { - GlassCard { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - status, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.weight(1f), - ) - TextButton(onClick = { vm.clearForeignStatus() }) { Text("OK") } + + items(activityTiles.chunked(2).size) { rowIndex -> + val pair = activityTiles.chunked(2)[rowIndex] + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + pair.forEach { tile -> + ActivityTileCard( + tile = tile, + modifier = Modifier.weight(1f), + onClick = { onNavigate(tile.route) }, + ) + } + if (pair.size == 1) Spacer(Modifier.weight(1f)) } } } } - item { - GlassCard { - LabeledValue("Model", info?.modelNumber ?: "—") - LabeledValue("Firmware", info?.firmwareRevision ?: "—") - LabeledValue( - "Battery", - battery?.let { "${it.charge}% · ${it.voltage} mV" } ?: "—", - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton(onClick = { vm.identify() }) { Text("Identify (LED)") } - if (state == DeviceState.Disconnected) { - TextButton(onClick = { vm.reconnect() }) { Text("Reconnect") } - } else { - TextButton(onClick = { - vm.disconnect() - onDisconnected() - }) { Text("Disconnect") } - } - } - } + if (isConnecting) { + ConnectingOverlay(deviceName = vm.displayName) } + } +} + +/** Activity tile: brand-orange icon over a title, one color across the grid. */ +@Composable +private fun ActivityTileCard(tile: ActivityTile, modifier: Modifier = Modifier, onClick: () -> Unit) { + Card( + onClick = onClick, + modifier = modifier.heightIn(min = 120.dp), + shape = BrandCardShape, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(tile.icon, contentDescription = null, tint = Palette.accent, modifier = Modifier.size(28.dp)) + Text(tile.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + } + } +} - item { SectionHeader("Features") } - items(features.size) { index -> - val feature = features[index] - GlassCard(modifier = Modifier.clickable { onNavigate(feature.route) }) { - Text(feature.title, style = MaterialTheme.typography.titleSmall) - Text(feature.subtitle, style = MaterialTheme.typography.bodySmall, color = GlassTextDim) +/** + * Scrim shown while the BLE handshake runs. Absorbs taps so a second + * connection can't be started against a device that isn't ready yet. + */ +@Composable +private fun ConnectingOverlay(deviceName: String) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.45f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + contentAlignment = Alignment.Center, + ) { + Card(shape = MaterialTheme.shapes.large) { + Column( + modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + CircularProgressIndicator() + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Connecting", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(2.dp)) + Text( + deviceName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } } diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/device/DeviceInfoScreen.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/device/DeviceInfoScreen.kt index 094a98f..80b00d9 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/device/DeviceInfoScreen.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/device/DeviceInfoScreen.kt @@ -1,66 +1,105 @@ package com.mbientlab.metawear.app.ui.device import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import com.mbientlab.metawear.app.ui.appViewModel -import com.mbientlab.metawear.app.ui.components.GlassCard +import com.mbientlab.metawear.app.ui.components.AppScaffold +import com.mbientlab.metawear.app.ui.components.GroupCard import com.mbientlab.metawear.app.ui.components.LabeledValue import com.mbientlab.metawear.app.ui.components.SectionHeader +import com.mbientlab.metawear.app.ui.components.cardListItemColors import com.mbientlab.metawear.app.vm.DeviceViewModel +import com.mbientlab.metawear.model.ModuleInfo +import com.mbientlab.metawear.protocol.Module +/** + * Identity rows from the Device Information service, then every module the + * SDK knows about — present ones with their implementation/revision bytes, + * absent ones kept in the list as "—" so what's on this board is obvious. + */ @Composable -fun DeviceInfoScreen() { +fun DeviceInfoScreen(onBack: () -> Unit) { val vm = appViewModel(::DeviceViewModel) val info by vm.deviceInfo.collectAsState() val battery by vm.battery.collectAsState() val mac by vm.macAddress.collectAsState() val modules by vm.modules.collectAsState() - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - item { Text("Device Info", style = MaterialTheme.typography.headlineSmall) } - - item { - GlassCard { - LabeledValue("Manufacturer", info?.manufacturer ?: "—") - LabeledValue("Model", info?.model?.displayName ?: "—") - LabeledValue("Model number", info?.modelNumber ?: "—") - LabeledValue("Serial", info?.serialNumber ?: "—") - LabeledValue("Firmware", info?.firmwareRevision ?: "—") - LabeledValue("Hardware", info?.hardwareRevision ?: "—") - LabeledValue("MAC", mac ?: vm.device?.identifier ?: "—") - LabeledValue("Battery", battery?.let { "${it.charge}% · ${it.voltage} mV" } ?: "—") + AppScaffold(title = "Device Info", onBack = onBack) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = padding.calculateTopPadding() + 4.dp, + bottom = padding.calculateBottomPadding() + 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { SectionHeader("Identity") } + item { + GroupCard { + LabeledValue("Model", info?.modelNumber ?: "—") + HorizontalDivider() + LabeledValue("Manufacturer", info?.manufacturer ?: "—") + HorizontalDivider() + LabeledValue("Serial", info?.serialNumber ?: "—") + HorizontalDivider() + LabeledValue("Firmware", info?.firmwareRevision ?: "—") + HorizontalDivider() + LabeledValue("Hardware", info?.hardwareRevision ?: "—") + HorizontalDivider() + LabeledValue("MAC", mac ?: vm.device?.identifier ?: "—", monospace = true) + HorizontalDivider() + LabeledValue("Battery", battery?.let { "${it.charge}% · ${it.voltage} mV" } ?: "—") + } } - } - item { SectionHeader("Modules") } - item { - GlassCard { - val present = modules.values.filter { it.isPresent }.sortedBy { it.module.value } - if (present.isEmpty()) { - Text("No module table — connect first.", style = MaterialTheme.typography.bodySmall) - } else { - present.forEach { module -> - LabeledValue( - module.module.name.lowercase().replaceFirstChar { it.uppercase() }, - "impl ${module.implementation} · rev ${module.revision}", - ) + item { SectionHeader("Modules") } + item { + GroupCard { + Module.entries.forEachIndexed { index, module -> + if (index > 0) HorizontalDivider() + ModuleRow(module, modules[module]) } } } } } } + +/** One Modules row: name left; "impl 0xNN · rev N" or a muted "—" right. */ +@Composable +private fun ModuleRow(module: Module, info: ModuleInfo?) { + val name = module.name.lowercase().replace('_', ' ').replaceFirstChar { it.uppercase() } + ListItem( + headlineContent = { Text(name) }, + trailingContent = { + if (info != null && info.isPresent) { + Text( + "impl ${hex(info.implementation)} · rev ${info.revision}", + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text("—", color = MaterialTheme.colorScheme.outline) + } + }, + colors = cardListItemColors(), + ) +} + +private fun hex(value: Int): String = "0x" + value.toString(16).uppercase().padStart(2, '0') diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/firmware/FirmwareScreen.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/firmware/FirmwareScreen.kt index b2c3df4..1c0f70d 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/firmware/FirmwareScreen.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/firmware/FirmwareScreen.kt @@ -1,122 +1,193 @@ package com.mbientlab.metawear.app.ui.firmware import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.Button +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import com.mbientlab.metawear.app.ui.appViewModel -import com.mbientlab.metawear.app.ui.components.ErrorBanner -import com.mbientlab.metawear.app.ui.components.GlassCard +import com.mbientlab.metawear.app.ui.components.ActionRow +import com.mbientlab.metawear.app.ui.components.AppScaffold +import com.mbientlab.metawear.app.ui.components.GroupCard import com.mbientlab.metawear.app.ui.components.LabeledValue -import com.mbientlab.metawear.app.ui.theme.GlassGood -import com.mbientlab.metawear.app.ui.theme.GlassTextDim +import com.mbientlab.metawear.app.ui.components.Notice +import com.mbientlab.metawear.app.ui.components.SectionFooter +import com.mbientlab.metawear.app.ui.components.SectionHeader +import com.mbientlab.metawear.app.ui.components.cardListItemColors +import com.mbientlab.metawear.app.ui.theme.Palette +import com.mbientlab.metawear.app.ui.theme.forText import com.mbientlab.metawear.app.vm.FirmwareUpdateViewModel +import com.mbientlab.metawear.firmware.DFUProgress +/** Stand-alone firmware page (the same section also lives inside Settings). */ @Composable -fun FirmwareScreen() { +fun FirmwareScreen(onBack: () -> Unit) { val vm = appViewModel(::FirmwareUpdateViewModel) val phase by vm.phase.collectAsState() - val context = LocalContext.current - - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - item { Text("Firmware", style = MaterialTheme.typography.headlineSmall) } - - item { - GlassCard { - LabeledValue("Installed version", vm.currentVersion ?: "—") - } + AppScaffold( + title = "Firmware", + onBack = onBack, + notice = (phase as? FirmwareUpdateViewModel.Phase.Failed)?.let { Notice(it.message) { vm.reset() } }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = padding.calculateTopPadding() + 4.dp, + bottom = padding.calculateBottomPadding() + 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { SectionHeader("Firmware") } + item { FirmwareSection(vm) } } + } +} + +/** + * Installed version, a catalog check, and the over-the-air update with a + * live progress readout — every state of [FirmwareUpdateViewModel.Phase] + * mapped onto rows of one card. + */ +@Composable +fun FirmwareSection(vm: FirmwareUpdateViewModel) { + val phase by vm.phase.collectAsState() + val context = LocalContext.current - item { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + GroupCard { + LabeledValue("Installed", vm.currentVersion ?: "—", monospace = true) + HorizontalDivider() when (val p = phase) { - FirmwareUpdateViewModel.Phase.Unknown -> { - Button(onClick = { vm.checkForUpdate() }, modifier = Modifier.fillMaxWidth()) { - Text("Check for Update") - } - } + FirmwareUpdateViewModel.Phase.Unknown -> + ActionRow("Check for Updates", Icons.Filled.Sync, onClick = { vm.checkForUpdate() }) - FirmwareUpdateViewModel.Phase.Checking -> { - GlassCard { - Text("Checking the MbientLab catalog…", style = MaterialTheme.typography.bodyMedium) - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - } + FirmwareUpdateViewModel.Phase.Checking -> ListItem( + headlineContent = { Text("Checking for updates…", color = MaterialTheme.colorScheme.onSurfaceVariant) }, + trailingContent = { CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) }, + colors = cardListItemColors(), + ) FirmwareUpdateViewModel.Phase.UpToDate -> { - GlassCard { - Text("Firmware is up to date", color = GlassGood, style = MaterialTheme.typography.titleSmall) - } + StatusRow("Up to date") + HorizontalDivider() + ActionRow("Check Again", Icons.Filled.Sync, onClick = { vm.checkForUpdate() }) } is FirmwareUpdateViewModel.Phase.UpdateAvailable -> { - GlassCard { - Text("Update available", style = MaterialTheme.typography.titleSmall) - LabeledValue("New version", p.build.firmwareRev) - LabeledValue("File", p.build.filename) - Button( - onClick = { vm.startUpdate(context) }, - modifier = Modifier.fillMaxWidth(), - ) { Text("Install Update") } - Text( - "Keep the board within range and charged. The board reboots into its " + - "bootloader during the flash.", - style = MaterialTheme.typography.bodySmall, - color = GlassTextDim, - ) - } + LabeledValue("Latest", p.build.firmwareRev, monospace = true) + HorizontalDivider() + ActionRow("Update Firmware", Icons.Filled.Download, onClick = { vm.startUpdate(context) }) } - is FirmwareUpdateViewModel.Phase.Updating -> { - GlassCard { - Text( - p.progress.state.name.lowercase().replace('_', ' ') - .replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.titleSmall, - ) - LinearProgressIndicator( - progress = { (p.progress.percentComplete / 100.0).toFloat().coerceIn(0f, 1f) }, - modifier = Modifier.fillMaxWidth(), - ) - Text( - "Part ${p.progress.currentPart}/${p.progress.totalParts}" + - if (p.progress.bytesPerSecond > 0) { - " · ${(p.progress.bytesPerSecond / 1024).toInt()} KB/s" - } else { - "" - }, - style = MaterialTheme.typography.bodySmall, - color = GlassTextDim, - ) - } - } + is FirmwareUpdateViewModel.Phase.Updating -> ProgressRow(p.progress) - FirmwareUpdateViewModel.Phase.Completed -> { - GlassCard { - Text("Update complete", color = GlassGood, style = MaterialTheme.typography.titleSmall) - LabeledValue("Installed version", vm.currentVersion ?: "—") - } - } + FirmwareUpdateViewModel.Phase.Completed -> StatusRow("Updated") is FirmwareUpdateViewModel.Phase.Failed -> { - ErrorBanner(p.message) { vm.reset() } + ListItem( + headlineContent = { Text(p.message) }, + leadingContent = { Icon(Icons.Filled.Warning, contentDescription = null, tint = Palette.warning) }, + colors = cardListItemColors(), + ) + HorizontalDivider() + ActionRow("Try Again", Icons.Filled.Sync, onClick = { vm.checkForUpdate() }) } } } + when (phase) { + is FirmwareUpdateViewModel.Phase.Updating -> SectionFooter( + "Updating firmware. Keep the app open with the board nearby and powered — do not disconnect.", + ) + is FirmwareUpdateViewModel.Phase.UpdateAvailable -> SectionFooter( + "Downloads the latest firmware from MbientLab and installs it over Bluetooth. " + + "The board restarts when finished.", + ) + else -> Unit + } + } +} + +@Composable +private fun StatusRow(text: String) { + ListItem( + headlineContent = { Text("Status") }, + trailingContent = { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = Palette.success, modifier = Modifier.size(18.dp)) + Text(text, color = Palette.success.forText()) + } + }, + colors = cardListItemColors(), + ) +} + +/** Active DFU phase plus a determinate bar while bytes are transferring. */ +@Composable +private fun ProgressRow(progress: DFUProgress) { + val label = when (progress.state) { + DFUProgress.State.FETCHING_CATALOG -> "Checking catalog…" + DFUProgress.State.DOWNLOADING_FIRMWARE -> "Downloading firmware…" + DFUProgress.State.BOOTLOADER_HANDOFF -> "Preparing device…" + DFUProgress.State.SCANNING -> "Locating device…" + DFUProgress.State.CONNECTING -> "Connecting…" + DFUProgress.State.STARTING -> "Starting transfer…" + DFUProgress.State.VALIDATING -> "Validating…" + DFUProgress.State.UPLOADING -> "Installing…" + DFUProgress.State.DISCONNECTING -> "Finishing up…" + DFUProgress.State.COMPLETED -> "Complete" + DFUProgress.State.ABORTED -> "Aborted" + } + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) + if (progress.totalParts > 1) { + Text( + "Step ${progress.currentPart} of ${progress.totalParts}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (progress.state == DFUProgress.State.UPLOADING) { + Text( + "${progress.percentComplete.toInt()}%", + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + } + if (progress.state == DFUProgress.State.UPLOADING) { + LinearProgressIndicator( + progress = { (progress.percentComplete / 100.0).toFloat().coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth(), + ) + } } } diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/logging/GroupLoggingScreen.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/logging/GroupLoggingScreen.kt index 674de8b..a239441 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/logging/GroupLoggingScreen.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/logging/GroupLoggingScreen.kt @@ -2,16 +2,35 @@ package com.mbientlab.metawear.app.ui.logging import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.DoNotDisturbOn +import androidx.compose.material.icons.filled.FiberManualRecord +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.PauseCircle +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.SaveAlt +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -20,20 +39,25 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import com.mbientlab.metawear.app.core.SensorKey import com.mbientlab.metawear.app.core.SensorSelection import com.mbientlab.metawear.app.data.LogSessionRecord import com.mbientlab.metawear.app.ui.appContainer -import com.mbientlab.metawear.app.ui.components.GlassCard +import com.mbientlab.metawear.app.ui.components.ActionRow +import com.mbientlab.metawear.app.ui.components.AppScaffold +import com.mbientlab.metawear.app.ui.components.GroupCard +import com.mbientlab.metawear.app.ui.components.RssiChip +import com.mbientlab.metawear.app.ui.components.SectionFooter import com.mbientlab.metawear.app.ui.components.SectionHeader +import com.mbientlab.metawear.app.ui.components.cardListItemColors +import com.mbientlab.metawear.app.ui.components.formatClock import com.mbientlab.metawear.app.ui.stream.SensorConfigSection -import com.mbientlab.metawear.app.ui.theme.GlassError -import com.mbientlab.metawear.app.ui.theme.GlassGood -import com.mbientlab.metawear.app.ui.theme.GlassTextDim -import com.mbientlab.metawear.app.ui.theme.GlassWarn +import com.mbientlab.metawear.app.ui.theme.Palette +import com.mbientlab.metawear.app.ui.theme.forText import com.mbientlab.metawear.app.vm.GroupCaptureCoordinator import kotlinx.coroutines.launch @@ -44,32 +68,24 @@ import kotlinx.coroutines.launch * lives on the app container. */ @Composable -fun GroupLoggingScreen() { +fun GroupLoggingScreen(onBack: () -> Unit, onSessionHistory: () -> Unit) { val container = appContainer() val coordinator = container.groupCapture val boards by coordinator.boards.collectAsState() val isBusy by coordinator.isBusy.collectAsState() val lastPass by coordinator.lastPass.collectAsState() - val demoMode by container.demoModeEnabled.collectAsState() val remembered by container.remembered.devices.collectAsState() val records by container.logSessions.records.collectAsState() + val rssiById by container.scanner.advertisementRssi.collectAsState() var selectedIds by remember { mutableStateOf(setOf()) } var selections by remember { mutableStateOf(listOf(SensorSelection(SensorKey.ACCELEROMETER))) } var showStopDialog by remember { mutableStateOf(false) } - // Candidates: remembered boards, plus the demo fleet when demo mode is on. - val candidates = buildList { - if (demoMode) { - container.demoFleet.forEachIndexed { index, device -> - add(device.identifier to container.demoName(index)) - } - } - remembered.forEach { device -> - if (none { it.first == device.mac }) add(device.mac to device.name) - } - } + // Candidates: every remembered board (a board must have been connected + // to once so the app knows it). + val candidates = remembered.map { it.mac to it.name } fun members(ids: Collection): List = ids.map { id -> GroupCaptureCoordinator.Member( @@ -78,141 +94,201 @@ fun GroupLoggingScreen() { ) } + fun isPending(status: LogSessionRecord.Status) = + status == LogSessionRecord.Status.RUNNING || status == LogSessionRecord.Status.STOPPED + // Records with a group id are the durable "fleet recording" signal // (they survive app restarts). - val activeGroupRecords = records.filter { - it.groupID != null && - (it.status == LogSessionRecord.Status.RUNNING || it.status == LogSessionRecord.Status.STOPPED) + val activeGroupRecords = records.filter { it.groupID != null && isPending(it.status) } + val activeByBoard = activeGroupRecords.groupBy { it.deviceId } + .entries.sortedBy { entry -> entry.value.minOf { it.startDate } } + val anyRunning = activeGroupRecords.any { it.status == LogSessionRecord.Status.RUNNING } + + // Hide "Last Run" when it would only duplicate the active-group section + // (a fully successful start pass: every board it lists is already shown, + // recording, under "Logging In Progress"). + val lastRunIsRedundant = !isBusy && activeGroupRecords.isNotEmpty() && + boards.all { it.phase == GroupCaptureCoordinator.BoardPhase.Logging } + val showProgress = (isBusy || boards.isNotEmpty()) && !lastRunIsRedundant + val failed = boards.filter { it.phase is GroupCaptureCoordinator.BoardPhase.Failed } + val savedAny = boards.any { + it.phase is GroupCaptureCoordinator.BoardPhase.Saved || it.phase is GroupCaptureCoordinator.BoardPhase.SavedWithIssues } - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - item { Text("Group Logging", style = MaterialTheme.typography.headlineSmall) } - item { - Text( - "Log the same sensors across several boards at once. Boards are armed one at " + - "a time; each keeps recording on its own until you collect.", - style = MaterialTheme.typography.bodySmall, - color = GlassTextDim, - ) - } + AppScaffold(title = "Group Logging", onBack = onBack) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = padding.calculateTopPadding() + 4.dp, + bottom = padding.calculateBottomPadding() + 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (showProgress) { + item { SectionHeader(if (isBusy) "Working…" else "Last Run") } + item { + GroupCard { + boards.forEachIndexed { index, board -> + if (index > 0) HorizontalDivider() + ListItem( + headlineContent = { Text(board.name) }, + supportingContent = { Text(phaseText(board.phase)) }, + leadingContent = { PhaseIcon(board.phase) }, + colors = cardListItemColors(), + ) + } + if (!isBusy && savedAny) { + HorizontalDivider() + ActionRow("View Saved Sessions", Icons.Filled.History, onClick = onSessionHistory) + } + if (!isBusy && lastPass == GroupCaptureCoordinator.PassKind.COLLECT && failed.isNotEmpty()) { + HorizontalDivider() + ActionRow( + "Retry Failed Download${if (failed.size == 1) "" else "s"}", + Icons.Filled.Refresh, + onClick = { + val retry = members(failed.map { it.id }) + container.appScope.launch { coordinator.stopAndDownloadAll(retry) } + }, + ) + } + } + } + if (!isBusy) { + item { + SectionFooter( + "Skipped boards keep their data — collect them here later, or connect to one " + + "directly and use its Logging screen.", + ) + } + } + } - item { SectionHeader("Boards") } - if (candidates.isEmpty()) { - item { - GlassCard { - Text( - "No boards yet — connect to a board once (or enable demo mode) so it " + - "appears here.", - style = MaterialTheme.typography.bodySmall, - color = GlassTextDim, + if (activeGroupRecords.isNotEmpty()) { + item { SectionHeader(if (anyRunning) "Logging In Progress" else "Ready To Collect") } + item { + GroupCard { + activeByBoard.forEachIndexed { index, (deviceId, boardRecords) -> + if (index > 0) HorizontalDivider() + val isRunning = boardRecords.any { it.status == LogSessionRecord.Status.RUNNING } + ListItem( + headlineContent = { Text(container.displayNameFor(deviceId) ?: deviceId) }, + supportingContent = { + Text( + if (isRunning) { + "${boardRecords.size} sensor${if (boardRecords.size == 1) "" else "s"} · " + + "since ${boardRecords.minOf { it.startDate }.formatClock()}" + } else { + "Stopped — awaiting download" + }, + ) + }, + leadingContent = { + Icon( + if (isRunning) Icons.Filled.FiberManualRecord else Icons.Filled.PauseCircle, + contentDescription = null, + tint = if (isRunning) Palette.danger else Palette.warning, + ) + }, + colors = cardListItemColors(), + ) + } + HorizontalDivider() + ActionRow( + if (anyRunning) "Stop & Download All" else "Download All", + Icons.Filled.SaveAlt, + onClick = { showStopDialog = true }, + enabled = !isBusy, + ) + } + } + item { + SectionFooter( + "The boards record on their own — you can close the app or leave. " + + "Come back here to collect everything at once.", ) } - } - } - items(candidates, key = { it.first }) { (id, name) -> - GlassCard { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text(name, style = MaterialTheme.typography.titleSmall) - Text(id, style = MaterialTheme.typography.bodySmall, color = GlassTextDim) - if (records.any { - it.deviceId == id && - ( - it.status == LogSessionRecord.Status.RUNNING || - it.status == LogSessionRecord.Status.STOPPED + } else if (!isBusy) { + item { SectionHeader("Boards") } + if (candidates.isEmpty()) { + item { + SectionFooter( + "No boards available — connect to a board once so it is remembered.", + ) + } + } else { + item { + GroupCard { + candidates.forEachIndexed { index, (id, name) -> + if (index > 0) HorizontalDivider() + val hasWaiting = records.any { it.deviceId == id && isPending(it.status) } + ListItem( + headlineContent = { Text(name) }, + supportingContent = { + Column { + Text(id, fontFamily = FontFamily.Monospace) + if (hasWaiting) { + Text("Has a session waiting — download it first", color = Palette.warning.forText()) + } + } + }, + leadingContent = { + Checkbox( + checked = id in selectedIds, + onCheckedChange = { checked -> + selectedIds = if (checked) selectedIds + id else selectedIds - id + }, ) + }, + trailingContent = rssiById[id]?.let { rssi -> { RssiChip(rssi) } }, + colors = cardListItemColors(), + ) } - ) { - Text( - "Has a session waiting", - style = MaterialTheme.typography.labelSmall, - color = GlassWarn, - ) } } - Checkbox( - checked = id in selectedIds, - onCheckedChange = { checked -> - selectedIds = if (checked) selectedIds + id else selectedIds - id - }, + } + item { + SectionFooter( + "Every selected board records the same sensors. Boards are set up one at a time and keep " + + "logging on their own — no connection needed while they record.", ) } - } - } - - item { SectionHeader("Shared sensors") } - item { - SensorConfigSection( - modules = emptyMap(), // fleet-wide config: no single module table to gate on - selections = selections, - onSelectionsChange = { selections = it }, - loggingMode = true, - // The multichannel thermometer and the humidity readable vary - // per board generation — keep the shared group config to the - // kinds every board runs identically. - excludeKeys = setOf(SensorKey.TEMPERATURE, SensorKey.HUMIDITY), - ) - } - - item { - Button( - onClick = { - val chosen = members(selectedIds) - container.appScope.launch { coordinator.startAll(chosen, selections) } - }, - enabled = !isBusy && selectedIds.isNotEmpty() && selections.isNotEmpty(), - modifier = Modifier.fillMaxWidth(), - ) { Text("Start Logging All (${selectedIds.size})") } - } - if (activeGroupRecords.isNotEmpty()) { - item { SectionHeader("Recording group") } - item { - GlassCard { - Text( - "${activeGroupRecords.map { it.deviceId }.distinct().size} board(s) have " + - "group sessions pending.", - style = MaterialTheme.typography.bodySmall, + item { + SensorConfigSection( + modules = emptyMap(), // fleet-wide config: no single module table to gate on + selections = selections, + onSelectionsChange = { selections = it }, + loggingMode = true, + // The multichannel thermometer and the humidity readable vary + // per board generation — keep the shared group config to the + // kinds every board runs identically. + excludeKeys = setOf(SensorKey.TEMPERATURE, SensorKey.HUMIDITY), ) - Button( - onClick = { showStopDialog = true }, - enabled = !isBusy, - modifier = Modifier.fillMaxWidth(), - ) { Text("Stop & Download All") } } - } - } - if (boards.isNotEmpty()) { - item { SectionHeader("Progress") } - items(boards, key = { it.id }) { board -> - GlassCard { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(board.name, style = MaterialTheme.typography.titleSmall) - PhaseText(board.phase) - } - } - } - val failed = boards.filter { it.phase is GroupCaptureCoordinator.BoardPhase.Failed } - if (failed.isNotEmpty() && !isBusy && lastPass == GroupCaptureCoordinator.PassKind.COLLECT) { item { - TextButton(onClick = { - val retry = members(failed.map { it.id }) - container.appScope.launch { coordinator.stopAndDownloadAll(retry) } - }) { Text("Retry Failed Download(s)") } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Button( + onClick = { + val chosen = members(selectedIds) + container.appScope.launch { coordinator.startAll(chosen, selections) } + }, + enabled = !isBusy && selectedIds.isNotEmpty() && selections.isNotEmpty(), + colors = ButtonDefaults.buttonColors(containerColor = Palette.danger, contentColor = Color.White), + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.FiberManualRecord, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Start Logging All") + } + SectionFooter( + if (selectedIds.isEmpty()) "Select at least one board above." + else "${selectedIds.size} board${if (selectedIds.size == 1) "" else "s"} will start logging.", + ) + } } } } @@ -221,11 +297,11 @@ fun GroupLoggingScreen() { if (showStopDialog) { AlertDialog( onDismissRequest = { showStopDialog = false }, - title = { Text("Stop & download all?") }, + title = { Text("Stop logging on all boards and download their data?") }, text = { Text( - "Every board in the group stops recording, then each is downloaded in turn. " + - "Keep the boards nearby.", + "Each board is collected in turn. Boards that are out of range are skipped — " + + "their data stays on the board for later.", ) }, confirmButton = { @@ -233,30 +309,50 @@ fun GroupLoggingScreen() { showStopDialog = false val ids = activeGroupRecords.map { it.deviceId }.distinct() container.appScope.launch { coordinator.stopAndDownloadAll(members(ids)) } - }) { Text("Stop & Download") } + }) { Text("Stop & Download All") } }, dismissButton = { - TextButton(onClick = { showStopDialog = false }) { Text("Cancel") } + OutlinedButton(onClick = { showStopDialog = false }) { Text("Cancel") } }, ) } } @Composable -private fun PhaseText(phase: GroupCaptureCoordinator.BoardPhase) { - val (text, color) = when (phase) { - GroupCaptureCoordinator.BoardPhase.Pending -> "Waiting…" to GlassTextDim - GroupCaptureCoordinator.BoardPhase.Connecting -> "Connecting…" to GlassTextDim - GroupCaptureCoordinator.BoardPhase.Starting -> "Starting…" to GlassTextDim - GroupCaptureCoordinator.BoardPhase.Verifying -> "Verifying…" to GlassTextDim - GroupCaptureCoordinator.BoardPhase.Logging -> "Logging" to GlassGood - GroupCaptureCoordinator.BoardPhase.Stopping -> "Stopping…" to GlassTextDim - GroupCaptureCoordinator.BoardPhase.Downloading -> "Downloading…" to GlassTextDim - is GroupCaptureCoordinator.BoardPhase.Saved -> "Saved ${phase.count} session(s)" to GlassGood +private fun PhaseIcon(phase: GroupCaptureCoordinator.BoardPhase) { + when (phase) { + GroupCaptureCoordinator.BoardPhase.Pending -> + Icon(Icons.Filled.RadioButtonUnchecked, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + GroupCaptureCoordinator.BoardPhase.Connecting, + GroupCaptureCoordinator.BoardPhase.Starting, + GroupCaptureCoordinator.BoardPhase.Verifying, + GroupCaptureCoordinator.BoardPhase.Stopping, + GroupCaptureCoordinator.BoardPhase.Downloading, + -> CircularProgressIndicator(modifier = Modifier.padding(2.dp).size(20.dp), strokeWidth = 2.dp) + GroupCaptureCoordinator.BoardPhase.Logging -> + Icon(Icons.Filled.FiberManualRecord, contentDescription = null, tint = Palette.danger) + is GroupCaptureCoordinator.BoardPhase.Saved -> + Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = Palette.success) is GroupCaptureCoordinator.BoardPhase.SavedWithIssues -> - "Saved ${phase.count} — ${phase.message}" to GlassWarn - is GroupCaptureCoordinator.BoardPhase.Skipped -> phase.message to GlassWarn - is GroupCaptureCoordinator.BoardPhase.Failed -> phase.message to GlassError + Icon(Icons.Filled.WarningAmber, contentDescription = null, tint = Palette.warning) + is GroupCaptureCoordinator.BoardPhase.Skipped -> + Icon(Icons.Filled.DoNotDisturbOn, contentDescription = null, tint = Palette.warning) + is GroupCaptureCoordinator.BoardPhase.Failed -> + Icon(Icons.Filled.Warning, contentDescription = null, tint = Palette.danger) } - Text(text, style = MaterialTheme.typography.labelMedium, color = color) +} + +private fun phaseText(phase: GroupCaptureCoordinator.BoardPhase): String = when (phase) { + GroupCaptureCoordinator.BoardPhase.Pending -> "Waiting…" + GroupCaptureCoordinator.BoardPhase.Connecting -> "Connecting…" + GroupCaptureCoordinator.BoardPhase.Starting -> "Starting loggers…" + GroupCaptureCoordinator.BoardPhase.Verifying -> "Confirming data is recording…" + GroupCaptureCoordinator.BoardPhase.Logging -> "Logging" + GroupCaptureCoordinator.BoardPhase.Stopping -> "Stopping…" + GroupCaptureCoordinator.BoardPhase.Downloading -> "Downloading…" + is GroupCaptureCoordinator.BoardPhase.Saved -> "Saved ${phase.count} session${if (phase.count == 1) "" else "s"}" + is GroupCaptureCoordinator.BoardPhase.SavedWithIssues -> + "Saved ${phase.count} session${if (phase.count == 1) "" else "s"} · ${phase.message}" + is GroupCaptureCoordinator.BoardPhase.Skipped -> phase.message + is GroupCaptureCoordinator.BoardPhase.Failed -> phase.message } diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/logging/LoggingScreen.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/logging/LoggingScreen.kt index 389ab62..5854240 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/logging/LoggingScreen.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/logging/LoggingScreen.kt @@ -1,14 +1,32 @@ package com.mbientlab.metawear.app.ui.logging import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.FiberManualRecord +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.outlined.Circle import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -16,25 +34,42 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import com.mbientlab.metawear.app.core.SensorKey import com.mbientlab.metawear.app.core.SensorSelection import com.mbientlab.metawear.app.data.LogSessionRecord import com.mbientlab.metawear.app.ui.appViewModel -import com.mbientlab.metawear.app.ui.components.ErrorBanner -import com.mbientlab.metawear.app.ui.components.GlassCard -import com.mbientlab.metawear.app.ui.components.LabeledValue +import com.mbientlab.metawear.app.ui.components.AppScaffold +import com.mbientlab.metawear.app.ui.components.BrandCard +import com.mbientlab.metawear.app.ui.components.GroupCard +import com.mbientlab.metawear.app.ui.components.Notice +import com.mbientlab.metawear.app.ui.components.SectionFooter import com.mbientlab.metawear.app.ui.components.SectionHeader +import com.mbientlab.metawear.app.ui.components.cardListItemColors +import com.mbientlab.metawear.app.ui.components.formatDuration +import com.mbientlab.metawear.app.ui.components.formatTime +import com.mbientlab.metawear.app.ui.components.icon +import com.mbientlab.metawear.app.ui.components.sensorIconForLabel import com.mbientlab.metawear.app.ui.stream.SensorConfigSection -import com.mbientlab.metawear.app.ui.theme.GlassGood -import com.mbientlab.metawear.app.ui.theme.GlassTextDim +import com.mbientlab.metawear.app.ui.theme.Palette +import com.mbientlab.metawear.app.ui.theme.forText import com.mbientlab.metawear.app.vm.DeviceViewModel import com.mbientlab.metawear.app.vm.DownloadViewModel import com.mbientlab.metawear.app.vm.LogSessionViewModel +import com.mbientlab.metawear.persistence.SessionSnapshot +/** + * On-board logging: sensor picker (locked while recording), a phase status + * row, the active loggers, and the download flow for stopped sessions. + * The primary action (Start / Stop / Download) lives in the top bar. + */ @Composable -fun LoggingScreen() { +fun LoggingScreen(onBack: () -> Unit) { val deviceVm = appViewModel(::DeviceViewModel) val vm = appViewModel(::LogSessionViewModel) val downloadVm = appViewModel(::DownloadViewModel) @@ -52,118 +87,245 @@ fun LoggingScreen() { } val deviceId = deviceVm.device?.identifier - val pending = records.filter { - it.deviceId == deviceId && it.status == LogSessionRecord.Status.STOPPED - } - - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - item { Text("Logging", style = MaterialTheme.typography.headlineSmall) } - item { ErrorBanner(lastError) { vm.clearError() } } + val running = records.filter { it.deviceId == deviceId && it.status == LogSessionRecord.Status.RUNNING } + val pending = records.filter { it.deviceId == deviceId && it.status == LogSessionRecord.Status.STOPPED } + val isRunning = phase is LogSessionViewModel.Phase.Running + val notice = lastError?.let { Notice(it) { vm.clearError() } } + ?: (downloadPhase as? DownloadViewModel.Phase.Failed)?.let { Notice(it.message) { downloadVm.reset() } } - when (val p = phase) { - is LogSessionViewModel.Phase.Running -> { - item { - GlassCard { - Text("Recording to flash", style = MaterialTheme.typography.titleSmall, color = GlassGood) - Text( - formatElapsed(elapsed), - style = MaterialTheme.typography.headlineMedium, - ) - Text( - "Started ${p.startedAt}", - style = MaterialTheme.typography.bodySmall, - color = GlassTextDim, - ) - } + AppScaffold( + title = "Logging", + onBack = onBack, + notice = notice, + actions = { + when { + isRunning -> OutlinedButton( + onClick = { vm.stop() }, + enabled = !isBusy, + modifier = Modifier.padding(end = 8.dp), + ) { + Icon(Icons.Filled.Stop, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + Text("Stop") } - item { - Button( - onClick = { vm.stop() }, - enabled = !isBusy, - modifier = Modifier.fillMaxWidth(), - ) { Text("Stop Logging") } + phase is LogSessionViewModel.Phase.BoardLogging -> OutlinedButton( + onClick = { vm.stopBoardLogging() }, + enabled = !isBusy, + modifier = Modifier.padding(end = 8.dp), + ) { + Icon(Icons.Filled.Stop, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + Text("Stop") + } + pending.isNotEmpty() && downloadPhase is DownloadViewModel.Phase.Idle -> Button( + onClick = { downloadVm.downloadAll(pending) }, + modifier = Modifier.padding(end = 8.dp), + ) { + Icon(Icons.Filled.Download, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + Text("Download") + } + else -> Button( + onClick = { vm.start(selections) }, + enabled = !isBusy && selections.isNotEmpty() && phase !is LogSessionViewModel.Phase.Running, + colors = ButtonDefaults.buttonColors(containerColor = Palette.danger, contentColor = Color.White), + modifier = Modifier.padding(end = 8.dp), + ) { + Icon(Icons.Filled.FiberManualRecord, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + Text("Start") } } - - else -> { - item { - SensorConfigSection( - modules = modules, - selections = selections, - onSelectionsChange = { selections = it }, - loggingMode = true, // hides stream-only altitude + }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = padding.calculateTopPadding() + 4.dp, + bottom = padding.calculateBottomPadding() + 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { + val (icon, tint, text) = when (val p = phase) { + LogSessionViewModel.Phase.Idle -> Triple( + Icons.Outlined.Circle, + MaterialTheme.colorScheme.onSurfaceVariant, + "Idle · tap Start to begin", + ) + is LogSessionViewModel.Phase.Running -> Triple( + Icons.Filled.FiberManualRecord, + Palette.danger, + "Logging · ${formatDuration(elapsed)} · started ${p.startedAt.formatTime()}", + ) + LogSessionViewModel.Phase.Stopped -> Triple( + Icons.Filled.CheckCircle, + Palette.success, + "Stopped · ${formatDuration(elapsed)} captured", + ) + is LogSessionViewModel.Phase.BoardLogging -> Triple( + Icons.Filled.FiberManualRecord, + Palette.warning, + "Logging · ${p.loggerCount} logger" + + (if (p.loggerCount == 1) "" else "s") + " · ${"%,d".format(p.entryCount)} entries", ) } - item { - Button( - onClick = { vm.start(selections) }, - enabled = !isBusy && selections.isNotEmpty(), - modifier = Modifier.fillMaxWidth(), - ) { Text("Start Logging") } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + BrandCard { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(icon, contentDescription = null, tint = tint) + Text(text, style = MaterialTheme.typography.bodyLarge, fontFamily = FontFamily.Monospace) + } + } + SectionFooter( + if (phase is LogSessionViewModel.Phase.BoardLogging) { + "The board is currently logging. This session was started earlier — by a previous " + + "run of the app or by another app — so its sensors aren't listed here. Stop ends " + + "it and keeps what was recorded; download or clear it from Settings." + } else { + "The board keeps logging even if you close the app." + }, + ) } } - } - if (pending.isNotEmpty()) { - item { SectionHeader("Pending download") } - items(pending, key = { it.id }) { record -> - GlassCard { - LabeledValue(record.selection.displayLabel, record.status.name.lowercase()) + if (pending.isNotEmpty() || downloadPhase !is DownloadViewModel.Phase.Idle) { + item { SectionHeader("Download") } + if (pending.isNotEmpty()) { + item { + GroupCard { + pending.forEachIndexed { index, record -> + if (index > 0) HorizontalDivider() + ListItem( + headlineContent = { Text(record.selection.displayLabel) }, + supportingContent = { Text("Stopped — awaiting download") }, + leadingContent = { + Icon(record.selection.key.icon, contentDescription = null, tint = Palette.accent) + }, + colors = cardListItemColors(), + ) + } + } + } } + item { DownloadStateCard(downloadPhase, onDismiss = { downloadVm.reset() }) } } + item { - when (val dp = downloadPhase) { - is DownloadViewModel.Phase.Downloading -> { - GlassCard { - Text("Downloading…", style = MaterialTheme.typography.titleSmall) - LinearProgressIndicator( - progress = { dp.progress.toFloat() }, - modifier = Modifier.fillMaxWidth(), - ) - Text( - "${dp.entries} entries" + (dp.total?.let { " of $it" } ?: ""), - style = MaterialTheme.typography.bodySmall, - color = GlassTextDim, - ) - } - } + // While recording, mirror the running loggers into the locked + // picker so the user sees what is actually being captured. + SensorConfigSection( + modules = modules, + selections = if (isRunning && running.isNotEmpty()) running.map { it.selection } else selections, + onSelectionsChange = { selections = it }, + loggingMode = true, // hides stream-only altitude + locked = isRunning, + ) + } - is DownloadViewModel.Phase.Ready -> { - GlassCard { - Text( - "Saved ${dp.snapshots.size} session(s) to history", - color = GlassGood, - style = MaterialTheme.typography.titleSmall, + if (isRunning && running.isNotEmpty()) { + item { SectionHeader("Active Loggers") } + item { + GroupCard { + running.forEachIndexed { index, record -> + if (index > 0) HorizontalDivider() + ListItem( + headlineContent = { Text(record.selection.displayLabel) }, + supportingContent = { Text(record.startDate.formatTime()) }, + leadingContent = { + Icon(record.selection.key.icon, contentDescription = null, tint = Palette.accent) + }, + colors = cardListItemColors(), ) - dp.warning?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = GlassTextDim) - } } } + } + } - is DownloadViewModel.Phase.Failed -> { - ErrorBanner(dp.message) { downloadVm.reset() } - } + } + } +} - DownloadViewModel.Phase.Idle -> { - Button( - onClick = { downloadVm.downloadAll(pending) }, - modifier = Modifier.fillMaxWidth(), - ) { Text("Download ${pending.size} session(s)") } - } +/** Progress / complete / failed card for the flash readout. */ +@Composable +private fun DownloadStateCard(phase: DownloadViewModel.Phase, onDismiss: () -> Unit) { + when (phase) { + DownloadViewModel.Phase.Idle -> Unit + is DownloadViewModel.Phase.Downloading -> BrandCard(verticalSpacing = 12.dp) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Download, contentDescription = null, tint = Palette.accent) + Column { + Text("Downloading", style = MaterialTheme.typography.titleSmall) + Text( + if (phase.total != null && phase.total > 0) "${phase.entries} / ${phase.total} entries" + else "Reading log length…", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } + LinearProgressIndicator( + progress = { phase.progress.toFloat().coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth(), + ) + Text( + "${(phase.progress * 100).toInt()}%", + style = MaterialTheme.typography.headlineSmall, + fontFamily = FontFamily.Monospace, + modifier = Modifier.align(Alignment.End), + ) + } + is DownloadViewModel.Phase.Ready -> Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 4.dp), + ) { + Icon(Icons.Filled.Verified, contentDescription = null, tint = Palette.success) + Text("Download complete", style = MaterialTheme.typography.titleSmall) + } + phase.warning?.let { warning -> + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 4.dp), + ) { + Icon(Icons.Filled.Warning, contentDescription = null, tint = Palette.warning, modifier = Modifier.size(16.dp)) + Text(warning, style = MaterialTheme.typography.bodySmall, color = Palette.warning.forText()) + } + } + GroupCard { + phase.snapshots.forEachIndexed { index, snapshot -> + if (index > 0) HorizontalDivider() + SessionDownloadRow(snapshot) + } + } + SectionFooter("Saved to Session History — open it from the device hub to chart, replay, or export.") + } + is DownloadViewModel.Phase.Failed -> BrandCard { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Warning, contentDescription = null, tint = Palette.danger) + Text("Download failed", style = MaterialTheme.typography.titleSmall) + } + Text(phase.message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + OutlinedButton(onClick = onDismiss) { Text("Dismiss") } } } } -private fun formatElapsed(totalSeconds: Int): String { - val minutes = totalSeconds / 60 - val seconds = totalSeconds % 60 - return "%d:%02d".format(minutes, seconds) +/** One downloaded-session row: sensor icon, label, sample count and start time. */ +@Composable +private fun SessionDownloadRow(snapshot: SessionSnapshot) { + val icon: ImageVector = sensorIconForLabel(snapshot.label) + ListItem( + headlineContent = { Text(snapshot.label ?: snapshot.sensorKind.replaceFirstChar { it.uppercase() }) }, + supportingContent = { + Text("${snapshot.sampleCount} samples · ${snapshot.startDate.formatTime()}", fontFamily = FontFamily.Monospace) + }, + leadingContent = { Icon(icon, contentDescription = null, tint = Palette.accent) }, + colors = cardListItemColors(), + ) } diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/scan/BrandScanBackground.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/scan/BrandScanBackground.kt new file mode 100644 index 0000000..4f2c9a3 --- /dev/null +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/scan/BrandScanBackground.kt @@ -0,0 +1,72 @@ +package com.mbientlab.metawear.app.ui.scan + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import com.mbientlab.metawear.app.ui.theme.Palette + +/** + * Decorative background for the connection screen: the brand's solid + * #FE9500 field, broken up with static white line-art so it doesn't read as + * a wall of orange — an oversized "m" watermark rising from the bottom + * corner and a set of concentric scan ripples radiating from the + * bottom-leading edge, a nod to what this screen does all day. No motion. + * + * In light mode a 25 % white veil lifts the orange toward cream so the + * white device cards don't jump too harshly off it; dark cards sit + * comfortably on the full orange, so no veil there. + */ +@Composable +fun BrandScanBackground(modifier: Modifier = Modifier) { + val isLight = !isSystemInDarkTheme() + val textMeasurer = rememberTextMeasurer() + Canvas(modifier = modifier.fillMaxSize()) { + val w = size.width + val h = size.height + + drawRect(Palette.accent) + if (isLight) drawRect(Color.White.copy(alpha = 0.25f)) + + // Scan ripples centred just off the bottom-leading corner. + for (ring in 1..5) { + drawCircle( + color = Color.White.copy(alpha = 0.12f), + radius = ring * w * 0.45f / 2f, + center = Offset(0f, h), + style = Stroke(width = 1.5.dp.toPx()), + ) + } + + // Oversized "m" peeking up from the bottom-trailing edge — the + // device lists live in the top half, so the mark fills the quiet + // space below them. + val fontSizeSp = (w * 0.95f).toSp() + val layout = textMeasurer.measure( + text = "m", + style = TextStyle( + fontSize = fontSizeSp, + fontWeight = FontWeight.SemiBold, + fontFamily = FontFamily.SansSerif, + color = Color.White.copy(alpha = 0.14f), + ), + ) + drawText( + textLayoutResult = layout, + topLeft = Offset( + x = w * 0.74f - layout.size.width / 2f, + y = h * 0.92f - layout.size.height / 2f, + ), + ) + } +} diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/scan/ScanScreen.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/scan/ScanScreen.kt index b0cdf4a..8df9cdd 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/scan/ScanScreen.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/scan/ScanScreen.kt @@ -5,18 +5,37 @@ import android.content.pm.PackageManager import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.clickable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.BluetoothDisabled +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.FiberManualRecord +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Layers +import androidx.compose.material.icons.filled.Sensors +import androidx.compose.material.icons.filled.SettingsInputAntenna +import androidx.compose.material.icons.filled.WifiTethering +import androidx.compose.material.icons.outlined.StopCircle +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -27,18 +46,27 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import com.mbientlab.metawear.DeviceState +import com.mbientlab.metawear.app.data.LogSessionRecord import com.mbientlab.metawear.app.data.RememberedDevice +import com.mbientlab.metawear.app.ui.appContainer import com.mbientlab.metawear.app.ui.appViewModel -import com.mbientlab.metawear.app.ui.components.ErrorBanner -import com.mbientlab.metawear.app.ui.components.GlassCard -import com.mbientlab.metawear.app.ui.components.RssiPill +import com.mbientlab.metawear.app.ui.components.AppScaffold +import com.mbientlab.metawear.app.ui.components.BrandCard +import com.mbientlab.metawear.app.ui.components.DeviceConnectionStatus +import com.mbientlab.metawear.app.ui.components.DeviceStatusChip +import com.mbientlab.metawear.app.ui.components.Notice import com.mbientlab.metawear.app.ui.components.SectionHeader -import com.mbientlab.metawear.app.ui.theme.GlassTextDim -import com.mbientlab.metawear.app.ui.theme.GlassWarn +import com.mbientlab.metawear.app.ui.components.cardListItemColors +import com.mbientlab.metawear.app.ui.theme.Palette import com.mbientlab.metawear.app.vm.ScannerViewModel +import kotlinx.coroutines.flow.MutableStateFlow /** Runtime permissions needed to scan/connect on this API level. */ private fun requiredBlePermissions(): Array = @@ -48,16 +76,29 @@ private fun requiredBlePermissions(): Array = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) } +/** + * Connection screen: remembered boards, nearby advertisers, and a link into + * session history — all on the brand orange field. + */ @Composable -fun ScanScreen(onDeviceSelected: () -> Unit, onGroupLogging: () -> Unit = {}) { +fun ScanScreen( + onDeviceSelected: () -> Unit, + onGroupLogging: () -> Unit = {}, + onSessionHistory: () -> Unit = {}, +) { val vm = appViewModel(::ScannerViewModel) + val container = appContainer() val context = LocalContext.current val isScanning by vm.isScanning.collectAsState() - val nearby by vm.nearbyDevices.collectAsState() + val nearbyAll by vm.nearbyDevices.collectAsState() val remembered by vm.remembered.collectAsState() - val demoMode by vm.demoModeEnabled.collectAsState() val lastError by vm.lastError.collectAsState() + val records by container.logSessions.records.collectAsState() + val activeId by container.activeDeviceId.collectAsState() + val activeState by remember(activeId) { + activeId?.let { container.device(it).state } ?: MutableStateFlow(DeviceState.Disconnected) + }.collectAsState() fun hasPermissions(): Boolean = requiredBlePermissions().all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED @@ -71,137 +112,245 @@ fun ScanScreen(onDeviceSelected: () -> Unit, onGroupLogging: () -> Unit = {}) { if (permissionsGranted) vm.startScan() } - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - item { - Text("MetaWear", style = MaterialTheme.typography.headlineMedium) - Text( - "Scan for nearby MetaMotion boards or run in demo mode.", - style = MaterialTheme.typography.bodyMedium, - color = GlassTextDim, - ) + fun toggleScan() { + when { + isScanning -> vm.stopScan() + hasPermissions() -> { + permissionsGranted = true + vm.startScan() + } + else -> permissionLauncher.launch(requiredBlePermissions()) } + } + + fun connect(identifier: String) { + vm.stopScan() + vm.select(identifier) + onDeviceSelected() + } - item { ErrorBanner(lastError) { vm.clearError() } } + val bluetoothAvailable = vm.isBluetoothAvailable() + val nearby = nearbyAll.filter { row -> remembered.none { it.mac == row.identifier } } + val hasActiveGroup = records.any { + it.groupID != null && + (it.status == LogSessionRecord.Status.RUNNING || it.status == LogSessionRecord.Status.STOPPED) + } + + fun hasPendingLog(mac: String) = records.any { + it.deviceId == mac && + (it.status == LogSessionRecord.Status.RUNNING || it.status == LogSessionRecord.Status.STOPPED) + } - item { - GlassCard { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + fun status(device: RememberedDevice): DeviceConnectionStatus { + val liveRssi = nearbyAll.firstOrNull { it.identifier == device.mac }?.rssi + return when { + activeId == device.mac && activeState == DeviceState.Connecting -> DeviceConnectionStatus.Connecting + activeId == device.mac && activeState != DeviceState.Disconnected -> DeviceConnectionStatus.Connected(liveRssi) + nearbyAll.any { it.identifier == device.mac } -> DeviceConnectionStatus.Available(liveRssi) + else -> DeviceConnectionStatus.Offline + } + } + + Box(Modifier.fillMaxSize()) { + BrandScanBackground() + AppScaffold( + title = "MetaWear", + containerColor = Color.Transparent, + notice = lastError?.let { Notice(it) { vm.clearError() } }, + topBarContentColor = Color.White, + actions = { + TextButton( + onClick = ::toggleScan, + colors = ButtonDefaults.textButtonColors(contentColor = Color.White), ) { - Column(Modifier.weight(1f)) { - Text("Demo mode", style = MaterialTheme.typography.titleSmall) - Text( - "Simulated MetaMotion S — no hardware needed", - style = MaterialTheme.typography.bodySmall, - color = GlassTextDim, - ) - } - Switch(checked = demoMode, onCheckedChange = { vm.setDemoMode(it) }) + Icon( + if (isScanning) Icons.Outlined.StopCircle else Icons.Filled.SettingsInputAntenna, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(6.dp)) + Text(if (isScanning) "Stop" else "Scan") } - if (!vm.isBluetoothAvailable() && !demoMode) { - Text( - "Bluetooth is off or unavailable on this device — enable demo mode to explore the app.", - style = MaterialTheme.typography.bodySmall, - color = GlassWarn, + IconButton(onClick = onGroupLogging) { + Icon( + Icons.Filled.Layers, + contentDescription = "Group logging", + tint = if (hasActiveGroup) Palette.danger else Color.White, ) } - } - } - - item { - Button( - onClick = { - if (isScanning) { - vm.stopScan() - } else if (hasPermissions()) { - permissionsGranted = true - vm.startScan() - } else { - permissionLauncher.launch(requiredBlePermissions()) - } - }, - modifier = Modifier.fillMaxWidth(), + }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = padding.calculateTopPadding() + 4.dp, + bottom = padding.calculateBottomPadding() + 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Text(if (isScanning) "Stop Scanning" else "Start Scanning") - } - } + item { SectionHeader("Remembered", color = Color.White) } + if (remembered.isEmpty()) { + item { ScanNote("No remembered devices yet.") } + } else { + items(remembered, key = { "remembered-${it.mac}" }) { device -> + RememberedDeviceRow( + device = device, + status = status(device), + hasPendingLog = hasPendingLog(device.mac), + onTap = { connect(device.mac) }, + onForget = { vm.forget(device.mac) }, + ) + } + } - item { - TextButton(onClick = onGroupLogging, modifier = Modifier.fillMaxWidth()) { - Text("Group Logging — record several boards at once") - } - } + item { SectionHeader("Nearby", color = Color.White) } + when { + !bluetoothAvailable -> item { + ScanNote( + "Bluetooth is off — turn it on to scan.", + icon = Icons.Filled.BluetoothDisabled, + tint = Palette.warning, + ) + } + nearby.isEmpty() -> item { + ScanNote(if (isScanning) "Scanning…" else "Tap Scan to look for devices.") + } + else -> items(nearby, key = { "nearby-${it.identifier}" }) { device -> + val isConnecting = activeId == device.identifier && activeState == DeviceState.Connecting + ScanRow( + icon = Icons.Filled.WifiTethering, + iconTint = Palette.info, + title = device.name.ifEmpty { "MetaWear" }, + subtitle = device.identifier, + trailing = { + DeviceStatusChip( + if (isConnecting) DeviceConnectionStatus.Connecting + else DeviceConnectionStatus.Available(device.rssi), + ) + }, + onTap = { connect(device.identifier) }, + ) + } + } - if (nearby.isNotEmpty()) { - item { SectionHeader("Nearby") } - items(nearby, key = { it.identifier }) { device -> - GlassCard( - modifier = Modifier.clickable { - vm.stopScan() - vm.select(device.identifier) - onDeviceSelected() - }, - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + item { + BrandCard( + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, + contentPadding = PaddingValues(0.dp), + onClick = onSessionHistory, ) { - Column(Modifier.weight(1f)) { - Text(device.name, style = MaterialTheme.typography.titleSmall) - Text( - if (device.isDemo) "Demo device" else device.identifier, - style = MaterialTheme.typography.bodySmall, - color = GlassTextDim, - ) - } - RssiPill(device.rssi) + ListItem( + headlineContent = { Text("Session History") }, + supportingContent = { Text("Saved streams and downloaded logs") }, + leadingContent = { + Icon(Icons.Filled.History, contentDescription = null, tint = Palette.accent) + }, + trailingContent = { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + }, + colors = cardListItemColors(), + ) } } } } + } +} - if (remembered.isNotEmpty()) { - item { SectionHeader("My Devices") } - items(remembered, key = { it.mac }) { device -> - RememberedRow( - device = device, - onConnect = { - vm.stopScan() - vm.select(device.mac) - onDeviceSelected() - }, - onForget = { vm.forget(device.mac) }, - ) - } - } +/** Short explanatory line on the orange field, in white. */ +@Composable +private fun ScanNote(text: String, icon: ImageVector? = null, tint: Color = Color.White) { + Row( + modifier = Modifier.padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(18.dp)) + Text(text, style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.9f)) + } +} + +/** One device row on a light card: icon, name, identifier, trailing status. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ScanRow( + icon: ImageVector, + iconTint: Color, + title: String, + subtitle: String?, + trailing: @Composable () -> Unit, + onTap: () -> Unit, + onLongPress: (() -> Unit)? = null, + titleBadge: (@Composable () -> Unit)? = null, + dimmed: Boolean = false, +) { + BrandCard( + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest.copy(alpha = if (dimmed) 0.75f else 1f), + contentPadding = PaddingValues(0.dp), + ) { + ListItem( + headlineContent = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(title) + titleBadge?.invoke() + } + }, + supportingContent = subtitle?.let { + { Text(it, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) } + }, + leadingContent = { Icon(icon, contentDescription = null, tint = iconTint) }, + trailingContent = trailing, + colors = cardListItemColors(), + modifier = Modifier.combinedClickable(onClick = onTap, onLongClick = onLongPress), + ) } } @Composable -private fun RememberedRow( +private fun RememberedDeviceRow( device: RememberedDevice, - onConnect: () -> Unit, + status: DeviceConnectionStatus, + hasPendingLog: Boolean, + onTap: () -> Unit, onForget: () -> Unit, ) { - GlassCard(modifier = Modifier.clickable(onClick = onConnect)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text(device.name, style = MaterialTheme.typography.titleSmall) - Text(device.mac, style = MaterialTheme.typography.bodySmall, color = GlassTextDim) - } - TextButton(onClick = onForget) { Text("Forget") } + var menuOpen by remember { mutableStateOf(false) } + Box { + ScanRow( + icon = Icons.Filled.Sensors, + iconTint = Palette.accent, + title = device.name.ifEmpty { "MetaWear" }, + subtitle = device.mac, + trailing = { DeviceStatusChip(status) }, + onTap = onTap, + onLongPress = { menuOpen = true }, + dimmed = status == DeviceConnectionStatus.Offline, + titleBadge = if (hasPendingLog) { + { + // Red, not amber: the board is actively recording — the + // same colour the state chip uses for "Logging". + Icon( + Icons.Filled.FiberManualRecord, + contentDescription = "Logging session waiting", + tint = Palette.danger, + modifier = Modifier.size(12.dp), + ) + } + } else { + null + }, + ) + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text("Forget device") }, + leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) }, + onClick = { + menuOpen = false + onForget() + }, + ) } } } diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/sessions/SessionDetailScreen.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/sessions/SessionDetailScreen.kt new file mode 100644 index 0000000..33a23c9 --- /dev/null +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/sessions/SessionDetailScreen.kt @@ -0,0 +1,168 @@ +package com.mbientlab.metawear.app.ui.sessions + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Sensors +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.mbientlab.metawear.app.core.AnyChartSample +import com.mbientlab.metawear.app.core.ReplayTimeline +import com.mbientlab.metawear.app.core.SessionAxisStyle +import com.mbientlab.metawear.app.export.CsvShare +import com.mbientlab.metawear.app.export.ExportFilename +import com.mbientlab.metawear.app.ui.appViewModel +import com.mbientlab.metawear.app.ui.components.AppScaffold +import com.mbientlab.metawear.app.ui.components.BrandCard +import com.mbientlab.metawear.app.ui.components.Notice +import com.mbientlab.metawear.app.ui.components.SensorChartCard +import com.mbientlab.metawear.app.ui.components.formatTime +import com.mbientlab.metawear.app.ui.components.sensorIconForLabel +import com.mbientlab.metawear.app.ui.theme.Palette +import com.mbientlab.metawear.app.vm.SessionHistoryViewModel +import com.mbientlab.metawear.persistence.SessionSnapshot +import java.text.NumberFormat +import kotlinx.coroutines.launch + +/** + * One saved session: stats card, the 3D replay for quaternion captures, the + * chart preview (most recent 600 samples), and CSV export. + */ +@Composable +fun SessionDetailScreen(sessionId: String, onBack: () -> Unit) { + val vm = appViewModel(::SessionHistoryViewModel) + val sessions by vm.sessions.collectAsState() + val lastError by vm.lastError.collectAsState() + val session = sessions.firstOrNull { it.id == sessionId } + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var preview by remember { mutableStateOf?>(null) } + var replay by remember { mutableStateOf, ReplayTimeline>?>(null) } + + LaunchedEffect(session?.id) { + val snapshot = session ?: return@LaunchedEffect + preview = vm.loadPreview(snapshot) + if (snapshot.sensorKind == "quaternion" && snapshot.sampleCount >= 2) { + replay = vm.loadReplay(snapshot)?.let { (samples, ticks) -> samples to ReplayTimeline(ticks) } + } + } + + AppScaffold( + title = session?.let { it.label ?: it.sensorKind.replaceFirstChar { c -> c.uppercase() } } ?: "Session", + onBack = onBack, + notice = lastError?.let { Notice(it) { vm.clearError() } }, + ) { padding -> + if (session == null) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@AppScaffold + } + val style = SessionAxisStyle.forSession( + sensorKind = session.sensorKind, + label = session.label, + channelCount = preview?.firstOrNull()?.channelCount ?: SessionAxisStyle.channelCountFor(session.sensorKind), + ) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = padding.calculateTopPadding() + 4.dp, + bottom = padding.calculateBottomPadding() + 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { StatsCard(session) } + replay?.let { (samples, timeline) -> + item { BrandCard { SessionReplayView(samples = samples, timeline = timeline) } } + } + preview?.let { samples -> + if (samples.isNotEmpty()) { + item { + SensorChartCard( + title = session.label?.substringBefore(" · ") + ?: session.sensorKind.replaceFirstChar { it.uppercase() }, + icon = sensorIconForLabel(session.label), + samples = samples, + latest = samples.last(), + style = style, + ) + } + } + } + item { + Button( + onClick = { + scope.launch { + val csv = vm.exportCsv(session) ?: return@launch + val filename = ExportFilename.make( + // Capture-time name attributes the file; un-renamed + // boards get their serial appended instead. + deviceName = session.deviceName ?: "MetaWear-${session.deviceSerial}", + sensorTag = session.sensorKind, + timestamp = session.startDate, + discriminator = session.id.take(4), + ) + CsvShare.share(context, filename, csv) + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Export CSV") + } + } + } + } +} + +@Composable +private fun StatsCard(session: SessionSnapshot) { + BrandCard(verticalSpacing = 6.dp) { + StatLine(Icons.Filled.Tag, "${NumberFormat.getIntegerInstance().format(session.sampleCount)} samples") + StatLine(Icons.Outlined.Schedule, "${session.startDate.formatTime()} – ${session.endDate.formatTime()}") + StatLine(Icons.Filled.Sensors, session.deviceName?.let { "$it · ${session.deviceModel}" } ?: session.deviceModel) + StatLine(Icons.Filled.Build, "Firmware ${session.deviceFirmware}") + session.groupID?.let { StatLine(Icons.Filled.Tag, "Group ${it.take(8)}") } + } +} + +@Composable +private fun StatLine(icon: ImageVector, text: String) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(icon, contentDescription = null, tint = Palette.accent, modifier = Modifier.size(18.dp)) + Text(text, style = MaterialTheme.typography.bodyMedium) + } +} diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/sessions/SessionReplayView.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/sessions/SessionReplayView.kt index 21d05fa..f4fb7ee 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/sessions/SessionReplayView.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/sessions/SessionReplayView.kt @@ -4,10 +4,15 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -17,11 +22,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import com.mbientlab.metawear.app.core.AnyChartSample import com.mbientlab.metawear.app.core.ReplayTimeline import com.mbientlab.metawear.app.ui.components.TaredQuaternionCube -import com.mbientlab.metawear.app.ui.theme.GlassTextDim import kotlinx.coroutines.delay /** @@ -59,41 +64,54 @@ fun SessionReplayView( Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { TaredQuaternionCube(latest = current) - Slider( - value = position.toFloat(), - onValueChange = { value -> - if (!isScrubbing) { - isScrubbing = true - wasPlayingBeforeScrub = isPlaying - isPlaying = false - } - position = value.toDouble() - }, - onValueChangeFinished = { - isScrubbing = false - if (wasPlayingBeforeScrub) isPlaying = true - }, - valueRange = 0f..maxOf(timeline.duration, 0.001).toFloat(), + Row( modifier = Modifier.fillMaxWidth(), - ) + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FilledIconButton(onClick = { isPlaying = !isPlaying }) { + Icon( + if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = if (isPlaying) "Pause" else "Play", + ) + } + Slider( + value = position.toFloat(), + onValueChange = { value -> + if (!isScrubbing) { + isScrubbing = true + wasPlayingBeforeScrub = isPlaying + isPlaying = false + } + position = value.toDouble() + }, + onValueChangeFinished = { + isScrubbing = false + if (wasPlayingBeforeScrub) isPlaying = true + }, + valueRange = 0f..maxOf(timeline.duration, 0.001).toFloat(), + modifier = Modifier.weight(1f), + ) + FilledTonalButton(onClick = { speed = if (speed >= 4.0) 1.0 else speed * 2 }) { + Text("${speed.toInt()}×", fontFamily = FontFamily.Monospace) + } + } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, ) { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - TextButton(onClick = { isPlaying = !isPlaying }) { - Text(if (isPlaying) "Pause" else "Play") - } - TextButton(onClick = { speed = if (speed >= 4.0) 1.0 else speed * 2 }) { - Text("${speed.toInt()}×") - } - } Text( - "${formatTime(position)} / ${formatTime(timeline.duration)}", + formatTime(position), + style = MaterialTheme.typography.labelMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + formatTime(timeline.duration), style = MaterialTheme.typography.labelMedium, - color = GlassTextDim, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/sessions/SessionsScreen.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/sessions/SessionsScreen.kt index fde10c2..57d33bf 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/sessions/SessionsScreen.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/sessions/SessionsScreen.kt @@ -1,96 +1,121 @@ package com.mbientlab.metawear.app.ui.sessions import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.mbientlab.metawear.app.core.AnyChartSample -import com.mbientlab.metawear.app.core.ReplayTimeline -import com.mbientlab.metawear.app.core.SessionAxisStyle -import com.mbientlab.metawear.app.export.CsvShare -import com.mbientlab.metawear.app.export.ExportFilename import com.mbientlab.metawear.app.ui.appViewModel -import com.mbientlab.metawear.app.ui.components.ErrorBanner -import com.mbientlab.metawear.app.ui.components.GlassCard -import com.mbientlab.metawear.app.ui.components.LabeledValue -import com.mbientlab.metawear.app.ui.components.LineChart +import com.mbientlab.metawear.app.ui.components.AppScaffold +import com.mbientlab.metawear.app.ui.components.BrandCard +import com.mbientlab.metawear.app.ui.components.BrandCardShape +import com.mbientlab.metawear.app.ui.components.Notice import com.mbientlab.metawear.app.ui.components.SectionHeader -import com.mbientlab.metawear.app.ui.theme.ChannelColors -import com.mbientlab.metawear.app.ui.theme.FourChannelColors -import com.mbientlab.metawear.app.ui.theme.GlassError -import com.mbientlab.metawear.app.ui.theme.GlassTextDim +import com.mbientlab.metawear.app.ui.components.cardListItemColors +import com.mbientlab.metawear.app.ui.components.formatDateTime +import com.mbientlab.metawear.app.ui.components.sensorIconForLabel +import com.mbientlab.metawear.app.ui.theme.Palette import com.mbientlab.metawear.app.vm.SessionHistoryViewModel import com.mbientlab.metawear.persistence.SessionSnapshot -import kotlinx.coroutines.launch +import java.text.NumberFormat /** - * Session history grouped by board, with swipe-to-delete, chart previews with - * per-sample-type styling, quaternion 3D replay, and CSV export. + * Session history grouped by board (sections keyed on board identity), one + * row per saved session with swipe-to-delete; tapping a row opens the + * detail screen with the chart preview, quaternion replay, and CSV export. */ @Composable -fun SessionsScreen() { +fun SessionsScreen(onBack: () -> Unit, onOpenSession: (String) -> Unit) { val vm = appViewModel(::SessionHistoryViewModel) val sections by vm.sections.collectAsState() val lastError by vm.lastError.collectAsState() - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - item { Text("Sessions", style = MaterialTheme.typography.headlineSmall) } - item { ErrorBanner(lastError) { vm.clearError() } } - + AppScaffold( + title = "Session History", + onBack = onBack, + notice = lastError?.let { Notice(it) { vm.clearError() } }, + ) { padding -> if (sections.isEmpty()) { - item { - GlassCard { - Text( - "No saved sessions yet. Stream (Stop & Save) or download a log to create one.", - style = MaterialTheme.typography.bodyMedium, - color = GlassTextDim, - ) - } + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(32.dp), + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Outlined.Schedule, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + Text("No sessions yet", style = MaterialTheme.typography.titleMedium) + Text( + "Downloaded log sessions and stopped live streams will appear here.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) } + return@AppScaffold } - - sections.forEach { section -> - item(key = "header-${section.id}") { SectionHeader(section.title) } - items(section.sessions, key = { it.id }) { session -> - DismissableSessionCard(session, vm) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = padding.calculateTopPadding() + 4.dp, + bottom = padding.calculateBottomPadding() + 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + sections.forEach { section -> + item(key = "header-${section.id}") { SectionHeader(section.title) } + items(section.sessions, key = { it.id }) { session -> + DismissableSessionRow( + session = session, + onOpen = { onOpenSession(session.id) }, + onDelete = { vm.deleteSession(session.id) }, + ) + } } } } } @Composable -private fun DismissableSessionCard(session: SessionSnapshot, vm: SessionHistoryViewModel) { +private fun DismissableSessionRow(session: SessionSnapshot, onOpen: () -> Unit, onDelete: () -> Unit) { val dismissState = rememberSwipeToDismissBoxState( confirmValueChange = { value -> if (value == SwipeToDismissBoxValue.EndToStart) { - vm.deleteSession(session.id) + onDelete() true } else { false @@ -104,86 +129,39 @@ private fun DismissableSessionCard(session: SessionSnapshot, vm: SessionHistoryV Box( modifier = Modifier .fillMaxSize() - .background(GlassError.copy(alpha = 0.4f)), + .background(Palette.danger, BrandCardShape), contentAlignment = Alignment.CenterEnd, ) { - Text("Delete", modifier = Modifier.padding(end = 24.dp)) + Icon( + Icons.Filled.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.onError, + modifier = Modifier.padding(end = 24.dp), + ) } }, ) { - SessionCard(session, vm) - } -} - -@Composable -private fun SessionCard(session: SessionSnapshot, vm: SessionHistoryViewModel) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - - var preview by remember { mutableStateOf?>(null) } - var replay by remember { mutableStateOf, ReplayTimeline>?>(null) } - - val isQuaternion = session.sensorKind == "quaternion" - val style = SessionAxisStyle.forSession( - sensorKind = session.sensorKind, - label = session.label, - channelCount = SessionAxisStyle.channelCountFor(session.sensorKind), - ) - - GlassCard { - Text(session.label ?: session.sensorKind, style = MaterialTheme.typography.titleSmall) - session.deviceName?.let { LabeledValue("Board", it) } - LabeledValue("Samples", session.sampleCount.toString()) - LabeledValue("Start", session.startDate.toString()) - LabeledValue("Device", "${session.deviceModel} · fw ${session.deviceFirmware}") - session.groupID?.let { LabeledValue("Group", it.take(8)) } - - replay?.let { (samples, timeline) -> - SessionReplayView(samples = samples, timeline = timeline) - } - - preview?.let { samples -> - LineChart( - samples = samples, - channelCount = style.chartChannels, - colors = if (style.chartChannels == 4) FourChannelColors else ChannelColors, - yRange = style.yRange, - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (isQuaternion && session.sampleCount >= 2 && replay == null) { - TextButton(onClick = { - scope.launch { - vm.loadReplay(session)?.let { (samples, ticks) -> - replay = samples to ReplayTimeline(ticks) - } + BrandCard(contentPadding = PaddingValues(0.dp)) { + ListItem( + headlineContent = { Text(session.label ?: session.sensorKind.replaceFirstChar { it.uppercase() }) }, + supportingContent = { + Column { + Text(session.startDate.formatDateTime()) + Text( + "${NumberFormat.getIntegerInstance().format(session.sampleCount)} samples", + fontFamily = FontFamily.Monospace, + ) } - }) { Text("Replay") } - } - if (preview == null) { - TextButton(onClick = { - scope.launch { preview = vm.loadPreview(session) } - }) { Text("Preview") } - } - TextButton(onClick = { - scope.launch { - val csv = vm.exportCsv(session) ?: return@launch - val filename = ExportFilename.make( - // Capture-time name attributes the file; un-renamed - // boards get their serial appended instead. - deviceName = session.deviceName ?: "MetaWear-${session.deviceSerial}", - sensorTag = session.sensorKind, - timestamp = session.startDate, - discriminator = session.id.take(4), - ) - CsvShare.share(context, filename, csv) - } - }) { Text("Export CSV") } - TextButton(onClick = { vm.deleteSession(session.id) }) { Text("Delete") } + }, + leadingContent = { + Icon(sensorIconForLabel(session.label), contentDescription = null, tint = Palette.accent) + }, + trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, + colors = cardListItemColors(), + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onOpen), + ) } } } diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/settings/SettingsScreen.kt index aea97eb..6b37ee6 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/settings/SettingsScreen.kt @@ -1,44 +1,78 @@ package com.mbientlab.metawear.app.ui.settings +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.DeleteSweep +import androidx.compose.material.icons.filled.EventBusy +import androidx.compose.material.icons.filled.FlashlightOff +import androidx.compose.material.icons.filled.Memory +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import com.mbientlab.metawear.app.ui.appViewModel -import com.mbientlab.metawear.app.ui.components.ErrorBanner -import com.mbientlab.metawear.app.ui.components.GlassCard +import com.mbientlab.metawear.app.ui.components.ActionRow +import com.mbientlab.metawear.app.ui.components.LabeledValue +import com.mbientlab.metawear.app.ui.components.AppScaffold +import com.mbientlab.metawear.app.ui.components.BrandCard +import com.mbientlab.metawear.app.ui.components.GroupCard +import com.mbientlab.metawear.app.ui.components.Notice +import com.mbientlab.metawear.app.ui.components.SectionFooter import com.mbientlab.metawear.app.ui.components.SectionHeader -import com.mbientlab.metawear.app.ui.theme.GlassError -import com.mbientlab.metawear.app.ui.theme.GlassGood -import com.mbientlab.metawear.app.ui.theme.GlassTextDim +import com.mbientlab.metawear.app.ui.firmware.FirmwareSection +import com.mbientlab.metawear.app.ui.theme.Palette +import com.mbientlab.metawear.app.vm.FirmwareUpdateViewModel import com.mbientlab.metawear.app.vm.SettingsViewModel import com.mbientlab.metawear.sensor.Settings +/** + * Device settings: rename, firmware, advertising, maintenance, factory + * reset. Every destructive action confirms through a centered dialog. + */ @Composable -fun SettingsScreen(onFactoryReset: () -> Unit) { +fun SettingsScreen(onFactoryReset: () -> Unit, onBack: () -> Unit) { val vm = appViewModel(::SettingsViewModel) val statusMessage by vm.statusMessage.collectAsState() val lastError by vm.lastError.collectAsState() val didFactoryReset by vm.didFactoryReset.collectAsState() + val loggingStatus by vm.loggingStatus.collectAsState() + val loggingBusy by vm.loggingBusy.collectAsState() + + val firmwareVm = appViewModel(::FirmwareUpdateViewModel) var name by remember { mutableStateOf("") } var intervalMs by remember { mutableStateOf("417") } @@ -48,217 +82,330 @@ fun SettingsScreen(onFactoryReset: () -> Unit) { var showClearMacrosDialog by remember { mutableStateOf(false) } var showClearEventsDialog by remember { mutableStateOf(false) } var showRestartDialog by remember { mutableStateOf(false) } + var showClearLogsDialog by remember { mutableStateOf(false) } - androidx.compose.runtime.LaunchedEffect(didFactoryReset) { + LaunchedEffect(didFactoryReset) { // Board rebooted; return to the device hub, which offers Reconnect. if (didFactoryReset) onFactoryReset() } - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - item { Text("Settings", style = MaterialTheme.typography.headlineSmall) } - item { ErrorBanner(lastError) { vm.clearMessages() } } - statusMessage?.let { message -> + val notice = lastError?.let { Notice(it) { vm.clearMessages() } } + ?: statusMessage?.let { Notice(it) { vm.clearMessages() } } + + AppScaffold(title = "Settings", onBack = onBack, notice = notice) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = padding.calculateTopPadding() + 4.dp, + bottom = padding.calculateBottomPadding() + 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { SectionHeader("Rename") } item { - GlassCard { Text(message, color = GlassGood, style = MaterialTheme.typography.bodySmall) } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + BrandCard(verticalSpacing = 10.dp) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Device name") }, + singleLine = true, + isError = name.isNotEmpty() && !vm.isNameValid(name), + modifier = Modifier.fillMaxWidth(), + ) + FilledTonalButton( + onClick = { vm.rename(name) }, + enabled = vm.isNameValid(name), + ) { + Icon( + Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.padding(end = 6.dp), + ) + Text("Save") + } + } + SectionFooter( + "Up to 26 characters: letters, numbers, spaces, _ and -. The name updates in the app " + + "immediately and on the board's next advertisement.", + ) + } } - } - item { SectionHeader("Device name") } - item { - GlassCard { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Advertising name (max 26 ASCII chars)") }, - isError = name.isNotEmpty() && !vm.isNameValid(name), - modifier = Modifier.fillMaxWidth(), - ) - Button( - onClick = { vm.rename(name) }, - enabled = vm.isNameValid(name), - modifier = Modifier.fillMaxWidth(), - ) { Text("Save Name") } - } - } + item { SectionHeader("Firmware") } + item { FirmwareSection(firmwareVm) } - item { SectionHeader("Advertising") } - item { - GlassCard { - OutlinedTextField( - value = intervalMs, - onValueChange = { intervalMs = it.filter(Char::isDigit).take(5) }, - label = { Text("Interval (ms, 20–10240)") }, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = timeoutSec, - onValueChange = { timeoutSec = it.filter(Char::isDigit).take(3) }, - label = { Text("Timeout (s, 0 = forever)") }, - modifier = Modifier.fillMaxWidth(), - ) - Text("TX power", style = MaterialTheme.typography.labelMedium, color = GlassTextDim) - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Settings.TxPower.entries.forEach { power -> - FilterChip( - selected = txPower == power, - onClick = { - txPower = power - vm.setTxPower(power) + item { SectionHeader("Logging") } + item { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + GroupCard { + val status = loggingStatus + LabeledValue( + "Entries in flash", + when { + status == null && loggingBusy -> "Reading…" + status == null -> "—" + else -> "%,d".format(status.entryCount) }, - label = { Text("${power.raw} dBm") }, + ) + HorizontalDivider() + LabeledValue( + "Active loggers", + when { + status == null -> "—" + status.activeLoggers.isEmpty() -> "None" + else -> status.activeLoggers.size.toString() + }, + ) + // One row per armed logger so a stale configuration + // from an earlier session (or another app) is visible. + // A ListItem (headline + supporting line) instead of a + // label/value pair: the detail string is wide and a + // two-column row would starve the label of width. + status?.activeLoggers?.forEach { logger -> + HorizontalDivider() + ListItem( + headlineContent = { Text("Logger ${logger.loggerID}") }, + supportingContent = { + Text( + "${logger.module.name.lowercase().replaceFirstChar { it.uppercase() }} · " + + "register 0x%02X · bytes %d–%d".format( + logger.register, + logger.chunkOffset, + logger.chunkOffset + logger.chunkLength - 1, + ), + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + HorizontalDivider() + ActionRow( + "Refresh", + Icons.Filled.Refresh, + enabled = !loggingBusy, + onClick = { vm.refreshLoggingStatus() }, + ) + HorizontalDivider() + ActionRow( + "Clear Logs & Loggers", + Icons.Filled.DeleteSweep, + tint = Palette.danger, + enabled = !loggingBusy, + onClick = { showClearLogsDialog = true }, ) } + SectionFooter( + "Loggers stay armed on the board across disconnects and app restarts, from every app that " + + "ever configured one. The entry count is reported in flash pages, so it can read 0 for the " + + "first seconds of a session. Clear removes every logger and drops all entries — " + + "download first if you want the data.", + ) + } + } + item { SectionHeader("Advertising") } + item { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + BrandCard(verticalSpacing = 10.dp) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = intervalMs, + onValueChange = { intervalMs = it.filter(Char::isDigit).take(5) }, + label = { Text("Interval (ms)") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = timeoutSec, + onValueChange = { timeoutSec = it.filter(Char::isDigit).take(3) }, + label = { Text("Timeout (s)") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "TX power", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Settings.TxPower.entries.forEach { power -> + FilterChip( + selected = txPower == power, + onClick = { + txPower = power + vm.setTxPower(power) + }, + label = { Text("${power.raw} dBm") }, + ) + } + } + FilledTonalButton( + onClick = { + vm.setAdvertising( + intervalMs = intervalMs.toIntOrNull() ?: 417, + timeoutSec = timeoutSec.toIntOrNull() ?: 0, + ) + }, + ) { Text("Apply") } + } + SectionFooter("Interval 20–10240 ms; timeout 0 advertises forever. TX power applies immediately.") } - Button( - onClick = { - vm.setAdvertising( - intervalMs = intervalMs.toIntOrNull() ?: 417, - timeoutSec = timeoutSec.toIntOrNull() ?: 0, - ) - }, - modifier = Modifier.fillMaxWidth(), - ) { Text("Apply Advertising") } } - } - item { SectionHeader("Maintenance") } - item { - GlassCard { - Button( - onClick = { vm.resetLed() }, - modifier = Modifier.fillMaxWidth(), - ) { Text("Reset LED") } - Button( - onClick = { showClearMacrosDialog = true }, - modifier = Modifier.fillMaxWidth(), - ) { Text("Clear Macros…") } - Button( - onClick = { showClearEventsDialog = true }, - modifier = Modifier.fillMaxWidth(), - ) { Text("Clear Events & Timers…") } - Button( - onClick = { showRestartDialog = true }, - modifier = Modifier.fillMaxWidth(), - ) { Text("Restart Board…") } - Text( - "Reset LED stops and clears the light immediately — if it relights after a " + - "disconnect, an on-board event is re-arming it; use Clear Events & Timers. " + - "Restart reboots the board without erasing logs or macros.", - style = MaterialTheme.typography.bodySmall, - color = GlassTextDim, - ) + item { SectionHeader("Maintenance") } + item { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + GroupCard { + ActionRow("Reset LED", Icons.Filled.FlashlightOff, onClick = { vm.resetLed() }) + HorizontalDivider() + ActionRow( + "Clear Macros", + Icons.Filled.Memory, + tint = Palette.danger, + onClick = { showClearMacrosDialog = true }, + ) + HorizontalDivider() + ActionRow( + "Clear Events & Timers", + Icons.Filled.EventBusy, + tint = Palette.danger, + onClick = { showClearEventsDialog = true }, + ) + HorizontalDivider() + ActionRow("Restart Board", Icons.Filled.Refresh, onClick = { showRestartDialog = true }) + } + SectionFooter( + "Reset LED stops and clears the light immediately — if it relights after a disconnect, " + + "an on-board event is re-arming it; use Clear Events & Timers. Restart reboots the " + + "board without erasing logs or macros.", + ) + } } - } - item { SectionHeader("Danger zone") } - item { - GlassCard { - Text( - "Factory reset erases all on-board log entries, loggers, events, " + - "data processors, and macros, then reboots the board.", - style = MaterialTheme.typography.bodySmall, - color = GlassTextDim, - ) - Button( - onClick = { showResetDialog = true }, - colors = androidx.compose.material3.ButtonDefaults.buttonColors(containerColor = GlassError), - modifier = Modifier.fillMaxWidth(), - ) { Text("Factory Reset…") } + item { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + GroupCard { + ActionRow( + "Factory Reset", + Icons.Filled.Warning, + tint = Palette.danger, + onClick = { showResetDialog = true }, + ) + } + SectionFooter("Erases all logs, processors, events, macros, and timers on the board, then reboots it.") + } } } } if (showClearMacrosDialog) { - AlertDialog( - onDismissRequest = { showClearMacrosDialog = false }, - title = { Text("Clear macros?") }, - text = { Text("Removes every on-boot macro recorded on the board.") }, - confirmButton = { - TextButton(onClick = { - showClearMacrosDialog = false - vm.clearMacros() - }) { Text("Clear Macros", color = GlassError) } - }, - dismissButton = { - TextButton(onClick = { showClearMacrosDialog = false }) { Text("Cancel") } + ConfirmDialog( + title = "Clear all macros on this board?", + text = "Removes every on-boot macro — including the identity broadcast this app configures. " + + "The app re-applies its broadcast automatically on the next connect.", + confirmLabel = "Clear Macros", + destructive = true, + onConfirm = { + showClearMacrosDialog = false + vm.clearMacros() }, + onDismiss = { showClearMacrosDialog = false }, ) } if (showClearEventsDialog) { - AlertDialog( - onDismissRequest = { showClearEventsDialog = false }, - title = { Text("Clear events & timers?") }, - text = { - Text( - "Removes every on-board event binding and timer — from ALL apps, including " + - "this one's recording heartbeat. Use this if the LED keeps relighting " + - "after disconnects.", - ) - }, - confirmButton = { - TextButton(onClick = { - showClearEventsDialog = false - vm.clearEvents() - }) { Text("Clear Events & Timers", color = GlassError) } + ConfirmDialog( + title = "Clear all events and timers?", + text = "Removes every on-board event binding and timer — from ALL apps, including this one's " + + "recording heartbeat. Use this if the LED keeps relighting after disconnects.", + confirmLabel = "Clear Events & Timers", + destructive = true, + onConfirm = { + showClearEventsDialog = false + vm.clearEvents() }, - dismissButton = { - TextButton(onClick = { showClearEventsDialog = false }) { Text("Cancel") } + onDismiss = { showClearEventsDialog = false }, + ) + } + + if (showClearLogsDialog) { + ConfirmDialog( + title = "Clear all logs and loggers?", + text = "Stops any on-board logging, drops every entry in flash, and removes every armed logger — " + + "from ALL apps. Any session that hasn't been downloaded is lost.", + confirmLabel = "Clear Logs & Loggers", + destructive = true, + onConfirm = { + showClearLogsDialog = false + vm.clearLogsAndLoggers() }, + onDismiss = { showClearLogsDialog = false }, ) } if (showRestartDialog) { - AlertDialog( - onDismissRequest = { showRestartDialog = false }, - title = { Text("Restart the board?") }, - text = { - Text( - "Reboots the board without erasing anything — logs and macros survive. " + - "The board will disconnect.", - ) - }, - confirmButton = { - TextButton(onClick = { - showRestartDialog = false - vm.restart() - }) { Text("Restart") } - }, - dismissButton = { - TextButton(onClick = { showRestartDialog = false }) { Text("Cancel") } + ConfirmDialog( + title = "Restart this MetaWear?", + text = "Reboots the board without erasing anything — logs and macros survive. The board will disconnect.", + confirmLabel = "Restart", + destructive = false, + onConfirm = { + showRestartDialog = false + vm.restart() }, + onDismiss = { showRestartDialog = false }, ) } if (showResetDialog) { - AlertDialog( - onDismissRequest = { showResetDialog = false }, - title = { Text("Factory reset?") }, - text = { - Text( - "All data on the board (logs, loggers, macros) will be erased and the " + - "board will reboot. This cannot be undone.", - ) - }, - confirmButton = { - TextButton(onClick = { - showResetDialog = false - vm.factoryReset() - }) { Text("Reset", color = GlassError) } - }, - dismissButton = { - TextButton(onClick = { showResetDialog = false }) { Text("Cancel") } + ConfirmDialog( + title = "Factory reset this MetaWear?", + text = "This will erase all on-device state. The board will reboot and disconnect.", + confirmLabel = "Reset", + destructive = true, + onConfirm = { + showResetDialog = false + vm.factoryReset() }, + onDismiss = { showResetDialog = false }, ) } } + +/** Centered confirmation dialog; the confirm action reads red when destructive. */ +@Composable +private fun ConfirmDialog( + title: String, + text: String, + confirmLabel: String, + destructive: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(text) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(confirmLabel, color = if (destructive) Palette.danger else LocalContentColor.current) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/stream/LiveStreamScreen.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/stream/LiveStreamScreen.kt index 5408b3a..a8ac6e1 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/stream/LiveStreamScreen.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/stream/LiveStreamScreen.kt @@ -1,15 +1,25 @@ package com.mbientlab.metawear.app.ui.stream import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -18,6 +28,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.mbientlab.metawear.app.core.SensorKey @@ -26,176 +37,169 @@ import com.mbientlab.metawear.app.export.CsvShare import com.mbientlab.metawear.app.export.ExportFilename import com.mbientlab.metawear.app.export.LiveBufferCsvExporter import com.mbientlab.metawear.app.ui.appViewModel -import com.mbientlab.metawear.app.ui.components.ErrorBanner +import com.mbientlab.metawear.app.ui.components.AppScaffold +import com.mbientlab.metawear.app.ui.components.BrandCard import com.mbientlab.metawear.app.ui.components.FusionCalibrationBadge -import com.mbientlab.metawear.app.ui.components.GlassCard -import com.mbientlab.metawear.app.ui.components.LineChart +import com.mbientlab.metawear.app.ui.components.Notice +import com.mbientlab.metawear.app.ui.components.SensorChartCard +import com.mbientlab.metawear.app.ui.components.SessionStatsBar import com.mbientlab.metawear.app.ui.components.TaredQuaternionCube -import com.mbientlab.metawear.app.ui.theme.ChannelColors -import com.mbientlab.metawear.app.ui.theme.FourChannelColors -import com.mbientlab.metawear.app.ui.theme.GlassTextDim +import com.mbientlab.metawear.app.ui.components.icon +import com.mbientlab.metawear.app.ui.theme.Palette import com.mbientlab.metawear.app.vm.Channel import com.mbientlab.metawear.app.vm.DeviceViewModel import com.mbientlab.metawear.app.vm.StreamSessionViewModel import java.util.Locale import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +/** + * Configure → stream in one screen: the sensor picker with a Start action + * until streaming begins, then the stats bar, calibration card, and one + * chart card per channel with Export / Pause / Stop in the top bar. + */ @Composable -fun LiveStreamScreen() { +fun LiveStreamScreen(onBack: () -> Unit) { val deviceVm = appViewModel(::DeviceViewModel) val vm = appViewModel(::StreamSessionViewModel) + val context = LocalContext.current val modules by deviceVm.modules.collectAsState() val channels by vm.channels.collectAsState() val isStreaming by vm.isStreaming.collectAsState() val isPaused by vm.isPaused.collectAsState() val isBusy by vm.isBusy.collectAsState() + val startedAt by vm.startedAt.collectAsState() val lastError by vm.lastError.collectAsState() - val calibration by vm.calibration.collectAsState() var selections by remember { mutableStateOf(listOf(SensorSelection(SensorKey.ACCELEROMETER))) } - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - item { Text("Live Stream", style = MaterialTheme.typography.headlineSmall) } - item { ErrorBanner(lastError) { vm.clearError() } } + fun exportAll() { + val timestamp = Clock.System.now() + val files = channels.map { channel -> + val csv = LiveBufferCsvExporter.export(channel.captureBuffer(), channel.selection) + val filename = ExportFilename.make( + deviceName = deviceVm.displayName, + sensorTag = channel.selection.key.shortTag, + timestamp = timestamp, + ) + filename to csv + } + CsvShare.shareAll(context, files) + } - if (!isStreaming) { - item { - SensorConfigSection( - modules = modules, - selections = selections, - onSelectionsChange = { selections = it }, - ) - } - item { + AppScaffold( + title = if (isStreaming) "Live Stream" else "Configure Stream", + onBack = onBack, + notice = lastError?.let { Notice(it) { vm.clearError() } }, + actions = { + if (!isStreaming) { Button( onClick = { vm.start(selections) }, enabled = !isBusy && selections.isNotEmpty(), - modifier = Modifier.fillMaxWidth(), - ) { Text("Start Streaming") } - } - } else { - item { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + colors = ButtonDefaults.buttonColors(containerColor = Palette.success, contentColor = Color.White), + modifier = Modifier.padding(end = 8.dp), ) { - OutlinedButton( - onClick = { vm.togglePause() }, - enabled = !isBusy, - modifier = Modifier.weight(1f), - ) { Text(if (isPaused) "Resume" else "Pause") } - Button( - onClick = { vm.stop() }, - enabled = !isBusy, - modifier = Modifier.weight(1f), - ) { Text("Stop & Save") } + Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + Text("Start") + } + } else { + IconButton(onClick = ::exportAll, enabled = channels.isNotEmpty()) { + Icon(Icons.Filled.Share, contentDescription = "Export CSV") + } + IconButton(onClick = { vm.togglePause() }, enabled = !isBusy) { + Icon( + if (isPaused) Icons.Filled.PlayArrow else Icons.Filled.Pause, + contentDescription = if (isPaused) "Resume" else "Pause", + ) + } + IconButton(onClick = { vm.stop() }, enabled = !isBusy) { + Icon(Icons.Filled.Stop, contentDescription = "Stop", tint = Palette.danger) } } - - calibration?.let { item { FusionCalibrationBadge(it) } } - - items(channels, key = { it.id }) { channel -> - ChannelCard(channel) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 12.dp, + end = 12.dp, + top = padding.calculateTopPadding() + 4.dp, + bottom = padding.calculateBottomPadding() + 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (!isStreaming) { + item { + SensorConfigSection( + modules = modules, + selections = selections, + onSelectionsChange = { selections = it }, + ) + } + } else { + startedAt?.let { started -> + item { StatsBar(started, channels) } + } + calibration?.let { item { FusionCalibrationBadge(it) } } + items(channels, key = { it.id }) { channel -> + ChannelCard(channel) + } } } } } +/** Stats strip whose sample total is summed live across every channel. */ +@Composable +private fun StatsBar(startedAt: Instant, channels: List) { + var total = 0 + channels.forEach { channel -> + val ui by channel.ui.collectAsState() + total += ui.totalSamples + } + SessionStatsBar(startedAt = startedAt, totalSamples = total) +} + @Composable private fun ChannelCard(channel: Channel) { val ui by channel.ui.collectAsState() - val style = channel.selection.key.axisStyle - val colors = if (style.chartChannels == 4) FourChannelColors else ChannelColors - val context = LocalContext.current + val key = channel.selection.key + val style = key.axisStyle - GlassCard { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(channel.selection.displayLabel, style = MaterialTheme.typography.titleSmall) - if (!channel.selection.key.isPolled) { - Text( - String.format(Locale.US, "%.1f Hz", ui.effectiveHz), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - } + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + // Live 3D orientation for the quaternion output — tared to a reference + // pose, with a Zero button to re-zero on demand. + if (key == SensorKey.FUSION_QUATERNION) { + BrandCard { TaredQuaternionCube(latest = ui.latest) } } - val latest = ui.latest - if (channel.selection.key.isPolled) { - // Readout tile: one big current value plus the (trivially reused) - // chart of recent readings. - Text( - latest?.let { String.format(Locale.US, "%.2f %s", it.f0, style.unit) } ?: "waiting…", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.primary, - ) - if (ui.displayBuffer.size >= 2) { - LineChart( - samples = ui.displayBuffer, - channelCount = 1, - colors = colors, - yRange = style.yRange, + SensorChartCard( + title = key.title, + icon = key.icon, + samples = ui.displayBuffer, + latest = ui.latest, + style = style, + rateLabel = if (key.isPolled) { + "every " + SensorSelection.formatPollInterval(channel.selection.effectivePollIntervalMs) + } else if (ui.effectiveHz > 0) { + String.format(Locale.US, "%.1f Hz", ui.effectiveHz) + } else { + null + }, + footer = { + Text( + "${ui.totalSamples} samples" + + (if (!key.isPolled) " · plotting 1/${channel.displayStride}" else "") + + (style.unit.takeIf { it.isNotEmpty() }?.let { " · $it" } ?: ""), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - } - Text( - "${ui.totalSamples} readings · every " + - SensorSelection.formatPollInterval(channel.selection.effectivePollIntervalMs), - style = MaterialTheme.typography.labelSmall, - color = GlassTextDim, - ) - } else { - // Live 3D orientation cube for the quaternion output — tared to - // a reference pose, with a Zero button to re-zero on demand. - if (channel.selection.key == SensorKey.FUSION_QUATERNION) { - TaredQuaternionCube(latest = latest) - } - LineChart( - samples = ui.displayBuffer, - channelCount = style.chartChannels, - colors = colors, - yRange = style.yRange, - ) - - // Live per-axis readout. - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - style.labels.take(style.chartChannels).forEachIndexed { index, label -> - Text( - "$label ${latest?.let { String.format(Locale.US, "%+.3f", it.channel(index)) } ?: "—"}", - style = MaterialTheme.typography.bodySmall, - color = colors.getOrElse(index) { GlassTextDim }, - ) - } - } - Text( - "${ui.totalSamples} samples · plotting 1/${channel.displayStride}" + - (style.unit.takeIf { it.isNotEmpty() }?.let { " · $it" } ?: ""), - style = MaterialTheme.typography.labelSmall, - color = GlassTextDim, - ) - } - androidx.compose.material3.TextButton(onClick = { - val csv = LiveBufferCsvExporter.export(channel.captureBuffer(), channel.selection) - val filename = ExportFilename.make( - deviceName = "MetaWear", - sensorTag = channel.selection.key.shortTag, - timestamp = Clock.System.now(), - ) - CsvShare.share(context, filename, csv) - }) { Text("Export buffer CSV") } + }, + ) } } diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/stream/SensorConfigSection.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/stream/SensorConfigSection.kt index 2c3b317..eff131a 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/stream/SensorConfigSection.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/stream/SensorConfigSection.kt @@ -1,27 +1,41 @@ package com.mbientlab.metawear.app.ui.stream +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.Checkbox import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.mbientlab.metawear.app.core.BandwidthAdvisor import com.mbientlab.metawear.app.core.SensorKey import com.mbientlab.metawear.app.core.SensorSelection -import com.mbientlab.metawear.app.ui.components.GlassCard -import com.mbientlab.metawear.app.ui.components.SectionHeader -import com.mbientlab.metawear.app.ui.theme.GlassTextDim -import com.mbientlab.metawear.app.ui.theme.GlassWarn +import com.mbientlab.metawear.app.ui.components.BandwidthCard +import com.mbientlab.metawear.app.ui.components.BrandCard +import com.mbientlab.metawear.app.ui.components.formatHz +import com.mbientlab.metawear.app.ui.components.icon +import com.mbientlab.metawear.app.ui.theme.Palette +import com.mbientlab.metawear.app.ui.theme.forText import com.mbientlab.metawear.model.ModuleInfo import com.mbientlab.metawear.protocol.Module @@ -48,10 +62,12 @@ fun availableSensors(modules: Map): List { /** * Multi-select sensor picker with per-sensor rate/range chips (streamed) or * polling-interval chips (polled readables), plus the bandwidth advisory. - * Shared by the live-stream and logging screens. + * Shared by the live-stream, logging, and group-logging screens. * * @param loggingMode hides stream-only signals (altitude) that can't be * captured to flash. + * @param locked disables every control — used while a log session is + * recording so the configuration can't be edited mid-session. */ @Composable fun SensorConfigSection( @@ -61,38 +77,86 @@ fun SensorConfigSection( loggingMode: Boolean = false, /** Kinds hidden outright (e.g. group logging excludes temperature/humidity). */ excludeKeys: Set = emptySet(), + locked: Boolean = false, ) { val available = availableSensors(modules) .filter { !loggingMode || it.canLog } .filterNot { it in excludeKeys } - val motion = available.filterNot { it.isEnvironmental } - val environmental = available.filter { it.isEnvironmental } + val groups = listOf( + "Motion" to available.filter { !it.isFusion && !it.isEnvironmental }, + "Fusion" to available.filter { it.isFusion }, + "Environmental" to available.filter { it.isEnvironmental }, + ).filter { it.second.isNotEmpty() } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (motion.isNotEmpty()) { - SectionHeader("Motion & Fusion") - motion.forEach { key -> - SensorCard(key, selections, onSelectionsChange) - } + groups.forEach { (title, keys) -> + CollapsibleSensorGroup( + title = title, + keys = keys, + selections = selections, + onSelectionsChange = onSelectionsChange, + locked = locked, + ) } - if (environmental.isNotEmpty()) { - SectionHeader("Environmental") - environmental.forEach { key -> - SensorCard(key, selections, onSelectionsChange) - } - } + BandwidthCard( + aggregateHz = BandwidthAdvisor.aggregateHz(selections), + onHalve = { onSelectionsChange(BandwidthAdvisor.halved(selections)) }, + ) + } +} + +/** + * One sensor group behind a tappable header. Starts expanded only when one + * of its sensors is already selected, so a screen with a single active + * sensor shows one open group and two folded ones — and the content below + * the picker (active loggers, download) stays within reach. A collapsed + * group still reports how many of its sensors are selected. + */ +@Composable +private fun CollapsibleSensorGroup( + title: String, + keys: List, + selections: List, + onSelectionsChange: (List) -> Unit, + locked: Boolean, +) { + val selectedCount = keys.count { key -> selections.any { it.key == key } } + var expanded by rememberSaveable(title) { mutableStateOf(selectedCount > 0) } - if (BandwidthAdvisor.isOverCeiling(selections)) { - GlassCard { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.small) + .clickable { expanded = !expanded } + .padding(horizontal = 4.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + title, + style = MaterialTheme.typography.titleSmall, + color = Palette.accent.forText(), + modifier = Modifier.weight(1f), + ) + if (selectedCount > 0) { Text( - "Combined rate ${BandwidthAdvisor.aggregateHz(selections).toInt()} Hz exceeds the " + - "~${BandwidthAdvisor.BLE_SAFE_CEILING_HZ.toInt()} Hz BLE ceiling — samples may drop.", - style = MaterialTheme.typography.bodySmall, - color = GlassWarn, + "$selectedCount selected", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 8.dp), ) - TextButton(onClick = { onSelectionsChange(BandwidthAdvisor.halved(selections)) }) { - Text("Halve all rates") + } + Icon( + if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = if (expanded) "Collapse $title" else "Expand $title", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + AnimatedVisibility(visible = expanded) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + keys.forEach { key -> + SensorCard(key, selections, onSelectionsChange, locked) } } } @@ -104,23 +168,32 @@ private fun SensorCard( key: SensorKey, selections: List, onSelectionsChange: (List) -> Unit, + locked: Boolean, ) { val selection = selections.firstOrNull { it.key == key } - GlassCard { + val onToggle: (Boolean) -> Unit = { checked -> + onSelectionsChange( + if (checked) selections + SensorSelection(key) + else selections.filterNot { it.key == key }, + ) + } + BrandCard(onClick = if (locked) null else ({ onToggle(selection == null) })) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text(key.title, style = MaterialTheme.typography.titleSmall) + Icon( + key.icon, + contentDescription = null, + tint = if (selection != null) Palette.accent else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + Text(key.title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f)) Checkbox( checked = selection != null, - onCheckedChange = { checked -> - onSelectionsChange( - if (checked) selections + SensorSelection(key) - else selections.filterNot { it.key == key }, - ) - }, + onCheckedChange = onToggle, + enabled = !locked, ) } if (selection != null) { @@ -128,11 +201,11 @@ private fun SensorCard( onSelectionsChange(selections.map { if (it.key == key) updated else it }) if (key.isPolled) { - IntervalRow(selection, ::update) + IntervalRow(selection, ::update, locked) } else { - RateRow(selection, ::update) + RateRow(selection, ::update, locked) if (key.rangeOptions.isNotEmpty()) { - RangeRow(selection, ::update) + RangeRow(selection, ::update, locked) } } } @@ -140,26 +213,28 @@ private fun SensorCard( } @Composable -private fun RateRow(selection: SensorSelection, onChange: (SensorSelection) -> Unit) { +private fun RateRow(selection: SensorSelection, onChange: (SensorSelection) -> Unit, locked: Boolean) { ChipRow("Rate") { selection.key.rateOptionsHz.forEach { hz -> FilterChip( selected = selection.hz == hz, onClick = { onChange(selection.copy(hz = hz)) }, label = { Text(formatHz(hz)) }, + enabled = !locked, ) } } } @Composable -private fun RangeRow(selection: SensorSelection, onChange: (SensorSelection) -> Unit) { +private fun RangeRow(selection: SensorSelection, onChange: (SensorSelection) -> Unit, locked: Boolean) { ChipRow("Range") { selection.key.rangeOptions.forEach { range -> FilterChip( selected = selection.range == range, onClick = { onChange(selection.copy(range = range)) }, label = { Text("±${range.toInt()} ${selection.key.rangeUnit}") }, + enabled = !locked, ) } } @@ -167,13 +242,14 @@ private fun RangeRow(selection: SensorSelection, onChange: (SensorSelection) -> /** Polling-interval chips (1 s … 5 m) for the environmental readables. */ @Composable -private fun IntervalRow(selection: SensorSelection, onChange: (SensorSelection) -> Unit) { +private fun IntervalRow(selection: SensorSelection, onChange: (SensorSelection) -> Unit, locked: Boolean) { ChipRow("Every") { selection.key.pollIntervalOptionsMs.forEach { intervalMs -> FilterChip( selected = selection.effectivePollIntervalMs == intervalMs, onClick = { onChange(selection.withPollInterval(intervalMs)) }, label = { Text(SensorSelection.formatPollInterval(intervalMs)) }, + enabled = !locked, ) } } @@ -188,10 +264,11 @@ private fun ChipRow(label: String, content: @Composable () -> Unit) { horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text(label, style = MaterialTheme.typography.labelMedium, color = GlassTextDim) + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) content() } } - -private fun formatHz(hz: Double): String = - if (hz == hz.toLong().toDouble()) "${hz.toLong()} Hz" else "$hz Hz" diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/theme/Palette.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/theme/Palette.kt new file mode 100644 index 0000000..921ed6e --- /dev/null +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/theme/Palette.kt @@ -0,0 +1,47 @@ +package com.mbientlab.metawear.app.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.luminance + +/** + * Brand palette. The accent is the MetaWear orange (#FE9500) sampled flat + * from the original connect screen; the semantic colors are deliberately + * soft so they read apart from it — the warning gold is pushed toward + * yellow, the danger red toward coral, and the info blue is dusty rather + * than saturated. + */ +object Palette { + /** Brand orange — the primary color in both light and dark schemes. */ + val accent = Color(0xFFFE9500) + + /** Soft teal-green: connected, calibrated, saved. */ + val success = Color(red = 0.30f, green = 0.70f, blue = 0.55f) + + /** Gold: connecting, idle, over-budget, bootloader. */ + val warning = Color(red = 0.93f, green = 0.74f, blue = 0.20f) + + /** Softened coral: disconnected, logging (recording), destructive. */ + val danger = Color(red = 0.90f, green = 0.40f, blue = 0.45f) + + /** Dusty blue: signal strength, streaming, medium calibration. */ + val info = Color(red = 0.40f, green = 0.60f, blue = 0.80f) + + /** Mid gray: offline / unknown. */ + val neutral = Color(red = 0.55f, green = 0.55f, blue = 0.55f) +} + +/** True when the active color scheme is the dark one. */ +@Composable +fun isDarkScheme(): Boolean = MaterialTheme.colorScheme.background.luminance() < 0.5f + +/** + * The palette color as it should be used for small TEXT: unchanged on dark + * surfaces, pulled a third of the way toward black on light ones so gold, + * teal, and orange labels stay legible on cream. Containers and icons keep + * the pure tint — only type needs the extra contrast. + */ +@Composable +fun Color.forText(): Color = if (isDarkScheme()) this else lerp(this, Color.Black, 0.35f) diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/theme/Theme.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/theme/Theme.kt index 3b622e5..43634ef 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/ui/theme/Theme.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/ui/theme/Theme.kt @@ -2,25 +2,21 @@ package com.mbientlab.metawear.app.ui.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight -// Dark-first "glass" palette: deep blue-black backgrounds, translucent card -// surfaces, cool cyan accent. (The app is dark-themed in both system modes.) - -val GlassBackground = Color(0xFF0B0F14) -val GlassSurface = Color(0xFF141B24) -val GlassSurfaceHigh = Color(0xFF1C2530) -val GlassAccent = Color(0xFF4FC3F7) -val GlassAccentDim = Color(0xFF2A5A70) -val GlassText = Color(0xFFE4EAF1) -val GlassTextDim = Color(0xFF8A97A6) -val GlassWarn = Color(0xFFFFB74D) -val GlassError = Color(0xFFEF6E6E) -val GlassGood = Color(0xFF69D18C) - -// Chart channel colors (x/red, y/green, z/blue, w/extra purple). +// Brand-first Material 3 theme: the MetaWear orange is the primary color in +// BOTH light and dark schemes (dynamic color is deliberately not applied so +// the brand always wins), the semantic colors come from [Palette], and the +// surfaces are warm-tinted neutrals so cards and lists sit comfortably next +// to the orange without competing with it. + +// Chart channel colors (x/red, y/green, z/blue, w/extra purple). These are +// DATA colors, not brand — kept distinct from the palette on purpose. val ChannelColors = listOf( Color(0xFFEF6E6E), // red Color(0xFF69D18C), // green @@ -36,26 +32,100 @@ val FourChannelColors = listOf( Color(0xFF5D9BF7), // z / yaw ) +private val LightScheme = lightColorScheme( + primary = Palette.accent, + // Dark brown on the orange (the launcher icon's "m" on its field) — far + // more legible than white at button sizes and just as on-brand. + onPrimary = Color(0xFF3D2200), + primaryContainer = Color(0xFFFFDDB8), + onPrimaryContainer = Color(0xFF4A2A00), + inversePrimary = Color(0xFFFFB866), + secondary = Color(0xFF7A5A3A), + onSecondary = Color.White, + secondaryContainer = Color(0xFFFFE6CC), + onSecondaryContainer = Color(0xFF3F2A12), + tertiary = Palette.info, + onTertiary = Color.White, + tertiaryContainer = Color(0xFFD9E6F5), + onTertiaryContainer = Color(0xFF1B3550), + error = Palette.danger, + onError = Color.White, + errorContainer = Color(0xFFFFDAD9), + onErrorContainer = Color(0xFF5C1A21), + background = Color(0xFFFFF8F1), + onBackground = Color(0xFF211A14), + surface = Color(0xFFFFF8F1), + onSurface = Color(0xFF211A14), + surfaceVariant = Color(0xFFF3E6D8), + onSurfaceVariant = Color(0xFF52463C), + surfaceContainerLowest = Color(0xFFFFFFFF), + surfaceContainerLow = Color(0xFFFDF2E8), + surfaceContainer = Color(0xFFF9EDE0), + surfaceContainerHigh = Color(0xFFF3E7DA), + surfaceContainerHighest = Color(0xFFEDE1D4), + outline = Color(0xFF85766A), + outlineVariant = Color(0xFFD8C9BB), + inverseSurface = Color(0xFF362F29), + inverseOnSurface = Color(0xFFFBEFE5), + scrim = Color.Black, +) + private val DarkScheme = darkColorScheme( - primary = GlassAccent, - onPrimary = Color(0xFF06222E), - secondary = GlassAccentDim, - onSecondary = GlassText, - background = GlassBackground, - onBackground = GlassText, - surface = GlassSurface, - onSurface = GlassText, - surfaceVariant = GlassSurfaceHigh, - onSurfaceVariant = GlassTextDim, - error = GlassError, + primary = Palette.accent, + onPrimary = Color(0xFF3D2200), + primaryContainer = Color(0xFF6A3E00), + onPrimaryContainer = Color(0xFFFFDDB8), + inversePrimary = Color(0xFF9A5A00), + secondary = Color(0xFFE6C3A0), + onSecondary = Color(0xFF432D14), + secondaryContainer = Color(0xFF5C4328), + onSecondaryContainer = Color(0xFFFFE6CC), + tertiary = Palette.info, + onTertiary = Color(0xFF0E2438), + tertiaryContainer = Color(0xFF2E4A66), + onTertiaryContainer = Color(0xFFD9E6F5), + error = Palette.danger, + onError = Color(0xFF3F0A12), + errorContainer = Color(0xFF7A2E36), + onErrorContainer = Color(0xFFFFDAD9), + background = Color(0xFF15110D), + onBackground = Color(0xFFEDE1D6), + surface = Color(0xFF15110D), + onSurface = Color(0xFFEDE1D6), + surfaceVariant = Color(0xFF3B322A), + onSurfaceVariant = Color(0xFFCDBEB0), + surfaceContainerLowest = Color(0xFF0F0C09), + surfaceContainerLow = Color(0xFF1C1712), + surfaceContainer = Color(0xFF221C16), + surfaceContainerHigh = Color(0xFF2C251E), + surfaceContainerHighest = Color(0xFF362E26), + outline = Color(0xFF968678), + outlineVariant = Color(0xFF4C4138), + inverseSurface = Color(0xFFEDE1D6), + inverseOnSurface = Color(0xFF362F29), + scrim = Color.Black, ) +/** Default Material type scale with semibold headline/title weights. */ +private val AppTypography: Typography = Typography().let { base -> + base.copy( + headlineLarge = base.headlineLarge.copy(fontWeight = FontWeight.SemiBold), + headlineMedium = base.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + headlineSmall = base.headlineSmall.copy(fontWeight = FontWeight.SemiBold), + titleLarge = base.titleLarge.copy(fontWeight = FontWeight.SemiBold), + titleMedium = base.titleMedium.copy(fontWeight = FontWeight.SemiBold), + titleSmall = base.titleSmall.copy(fontWeight = FontWeight.SemiBold), + ) +} + @Composable -fun MetaWearTheme(content: @Composable () -> Unit) { - // Dark in both modes — the glass look is dark-first by design. - isSystemInDarkTheme() +fun MetaWearTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { MaterialTheme( - colorScheme = DarkScheme, + colorScheme = if (darkTheme) DarkScheme else LightScheme, + typography = AppTypography, content = content, ) } diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/vm/DeviceViewModel.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/vm/DeviceViewModel.kt index 2b7adbc..1793e5e 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/vm/DeviceViewModel.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/vm/DeviceViewModel.kt @@ -10,7 +10,6 @@ import com.mbientlab.metawear.app.data.LogDownloader import com.mbientlab.metawear.app.data.OrphanLogState import com.mbientlab.metawear.app.data.RememberedDevice import com.mbientlab.metawear.app.data.foreignLogDecision -import com.mbientlab.metawear.app.demo.DemoBleTransport import com.mbientlab.metawear.clearLog import com.mbientlab.metawear.model.BatteryState import com.mbientlab.metawear.queryActiveLoggers @@ -72,7 +71,6 @@ class DeviceViewModel(private val container: AppContainer) : ViewModel() { val displayName: String get() { val id = device?.identifier ?: return "MetaWear" - if (id == DemoBleTransport.DEVICE_IDENTIFIER) return DemoBleTransport.DEVICE_NAME return container.scanner.advertisedNames.value[id]?.ifEmpty { null } ?: container.remembered.devices.value.firstOrNull { it.mac == id }?.name ?: id @@ -198,8 +196,6 @@ class DeviceViewModel(private val container: AppContainer) : ViewModel() { } private fun rememberDevice(device: MetaWearDevice) { - // The demo device is never persisted. - if (device.identifier == DemoBleTransport.DEVICE_IDENTIFIER) return val info = device.deviceInfo container.remembered.upsert( RememberedDevice( diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/vm/LogSessionViewModel.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/vm/LogSessionViewModel.kt index 0d20095..39b0124 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/vm/LogSessionViewModel.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/vm/LogSessionViewModel.kt @@ -9,7 +9,12 @@ import com.mbientlab.metawear.app.data.ConfiguredSensor import com.mbientlab.metawear.app.data.LogSessionRecord import com.mbientlab.metawear.app.data.startLoggingOn import com.mbientlab.metawear.app.data.stopLoggingOn +import com.mbientlab.metawear.clearLog import com.mbientlab.metawear.flushLogPage +import com.mbientlab.metawear.queryActiveLoggers +import com.mbientlab.metawear.sensor.LogLength +import com.mbientlab.metawear.sensor.LoggingEnabled +import com.mbientlab.metawear.stopAndRemoveLoggers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -30,6 +35,15 @@ class LogSessionViewModel(private val container: AppContainer) : ViewModel() { data object Idle : Phase() data class Running(val startedAt: Instant) : Phase() data object Stopped : Phase() + + /** + * The board is logging (loggers armed) but this app holds no running + * session for it — a session from an earlier run of the app, another + * app, or one whose record was lost. The screen offers to stop the + * board's logging (data can then be recovered as a foreign log) rather + * than showing a misleading Start. + */ + data class BoardLogging(val loggerCount: Int, val entryCount: Long) : Phase() } private val device: MetaWearDevice? = container.activeDevice() @@ -70,6 +84,70 @@ class LogSessionViewModel(private val container: AppContainer) : ViewModel() { _phase.value = Phase.Running(startedAt) startElapsedTicker(startedAt) } + // Local records are only a memory of what this app did; the board + // is the truth. Reconcile the two so the screen never shows Start + // while the board is logging, or Download for data that's gone. + reconcileWithBoard() + } + } + + /** + * Ask the board what it actually holds and reconcile the local records: + * + * - Loggers armed on the board but no local running session → the board + * is logging on its own; surface [Phase.BoardLogging]. + * - Local RUNNING/STOPPED records but the board holds no loggers and no + * entries → the session was cleared out from under them (Settings → + * Clear, factory reset, another app); drop them so no phantom Download. + * + * Best-effort: any read failure leaves the local view untouched. + */ + fun reconcileWithBoard() { + val device = device ?: return + viewModelScope.launch { + val loggers = runCatching { device.queryActiveLoggers() }.getOrNull() ?: return@launch + val entries = runCatching { device.read(LogLength()).value }.getOrDefault(0L) + val local = container.logSessions.pendingFor(device.identifier) + val localRunning = local.any { it.status == LogSessionRecord.Status.RUNNING } + + when { + loggers.isNotEmpty() && !localRunning -> { + // Stale STOPPED records for a board that is now logging + // again refer to data that no longer maps to them. + if (local.isNotEmpty()) container.logSessions.remove(local.map { it.id }) + _phase.value = Phase.BoardLogging(loggers.size, entries) + } + loggers.isEmpty() && entries == 0L && local.isNotEmpty() -> { + container.logSessions.remove(local.map { it.id }) + activeSensors.clear() + elapsedJob?.cancel() + _phase.value = Phase.Idle + } + loggers.isEmpty() && _phase.value is Phase.BoardLogging -> _phase.value = Phase.Idle + } + } + } + + /** + * Stop logging that the board is doing on its own (see [Phase.BoardLogging]): + * flush the last page so whatever landed is readable, then disable the + * logging module and remove its loggers — entries are KEPT, so the data + * can still be recovered (Settings → Clear drops it if unwanted). + */ + fun stopBoardLogging() { + val device = device ?: return + viewModelScope.launch { + _isBusy.value = true + try { + runCatching { device.flushLogPage() } + device.stopAndRemoveLoggers() + _phase.value = Phase.Idle + reconcileWithBoard() + } catch (e: Exception) { + _lastError.value = e.message ?: "Couldn't stop the board's logging" + } finally { + _isBusy.value = false + } } } diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/vm/ScannerViewModel.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/vm/ScannerViewModel.kt index eeaa9bc..b67e44d 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/vm/ScannerViewModel.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/vm/ScannerViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.mbientlab.metawear.app.AppContainer import com.mbientlab.metawear.app.core.DeviceFreshness -import com.mbientlab.metawear.app.demo.DemoBleTransport import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -16,9 +15,8 @@ import kotlinx.datetime.Clock import kotlinx.datetime.Instant /** - * Scan-screen state: nearby devices merged from the SDK scanner's StateFlows, - * remembered devices from the prefs store, plus the demo device when demo - * mode is on. + * Scan-screen state: nearby devices merged from the SDK scanner's StateFlows + * plus remembered devices from the prefs store. */ class ScannerViewModel(private val container: AppContainer) : ViewModel() { @@ -27,13 +25,11 @@ class ScannerViewModel(private val container: AppContainer) : ViewModel() { val identifier: String, val name: String, val rssi: Int?, - val isDemo: Boolean = false, ) private val scanner = container.scanner val isScanning: StateFlow = scanner.isScanning - val demoModeEnabled: StateFlow = container.demoModeEnabled val remembered = container.remembered.devices private val _lastError = MutableStateFlow(null) @@ -67,16 +63,15 @@ class ScannerViewModel(private val container: AppContainer) : ViewModel() { } } - /** Nearby devices: discovered + fresh, demo device prepended when enabled. */ + /** Nearby devices: discovered and still fresh, sorted by name. */ val nearbyDevices: StateFlow> = combine( scanner.discoveredDevices, scanner.advertisedNames, scanner.advertisementRssi, - container.demoModeEnabled, lastSeen, - ) { discovered, names, rssi, demo, seen -> + ) { discovered, names, rssi, seen -> val now = Clock.System.now() - val rows = discovered.keys + discovered.keys .filter { DeviceFreshness.isFresh(seen[it], now) || !isScanningNow() } .map { id -> NearbyDevice( @@ -86,11 +81,6 @@ class ScannerViewModel(private val container: AppContainer) : ViewModel() { ) } .sortedBy { it.name } - if (demo) { - listOf(NearbyDevice(DemoBleTransport.DEVICE_IDENTIFIER, DemoBleTransport.DEVICE_NAME, rssi = -52, isDemo = true)) + rows - } else { - rows - } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) private fun isScanningNow(): Boolean = scanner.isScanning.value @@ -103,7 +93,7 @@ class ScannerViewModel(private val container: AppContainer) : ViewModel() { fun startScan() { if (!container.isBluetoothAvailable()) { - _lastError.value = "Bluetooth is unavailable — try demo mode" + _lastError.value = "Bluetooth is off — turn it on to scan" return } runCatching { scanner.startScan() } @@ -114,10 +104,6 @@ class ScannerViewModel(private val container: AppContainer) : ViewModel() { runCatching { scanner.stopScan() } } - fun setDemoMode(enabled: Boolean) { - container.demoModeEnabled.value = enabled - } - /** Mark [identifier] active and hand off to the device detail flow. */ fun select(identifier: String) { container.activeDeviceId.value = identifier diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/vm/SettingsViewModel.kt b/app/src/main/kotlin/com/mbientlab/metawear/app/vm/SettingsViewModel.kt index 667a57a..4999923 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/vm/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/mbientlab/metawear/app/vm/SettingsViewModel.kt @@ -4,8 +4,13 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.mbientlab.metawear.MetaWearDevice import com.mbientlab.metawear.app.AppContainer +import com.mbientlab.metawear.clearLog +import com.mbientlab.metawear.model.ActiveLogger +import com.mbientlab.metawear.queryActiveLoggers +import com.mbientlab.metawear.sensor.LogLength import com.mbientlab.metawear.sensor.Settings import com.mbientlab.metawear.sensor.eraseAllMacros +import com.mbientlab.metawear.stopOnBoardLogging import com.mbientlab.metawear.sensor.removeAllEvents import com.mbientlab.metawear.sensor.removeAllTimers import com.mbientlab.metawear.sensor.stopLed @@ -32,6 +37,71 @@ class SettingsViewModel(private val container: AppContainer) : ViewModel() { private val _didFactoryReset = MutableStateFlow(false) val didFactoryReset: StateFlow = _didFactoryReset.asStateFlow() + // ---- On-board logging state ---- + + /** What the board's logging module currently holds. */ + data class LoggingStatus( + /** Entries in flash as reported by LOG_LENGTH (page-granular on MMS). */ + val entryCount: Long, + /** Loggers armed on the board — from every app that ever configured one. */ + val activeLoggers: List, + ) + + private val _loggingStatus = MutableStateFlow(null) + /** `null` until the first refresh completes (or the device is absent). */ + val loggingStatus: StateFlow = _loggingStatus.asStateFlow() + + private val _loggingBusy = MutableStateFlow(false) + /** True while a refresh or clear is in flight. */ + val loggingBusy: StateFlow = _loggingBusy.asStateFlow() + + init { + refreshLoggingStatus() + } + + /** Re-read the entry count and enumerate the loggers armed on the board. */ + fun refreshLoggingStatus() { + val device = device ?: return + viewModelScope.launch { + _loggingBusy.value = true + runCatching { + val count = device.read(LogLength()).value + val loggers = device.queryActiveLoggers() + LoggingStatus(entryCount = count, activeLoggers = loggers) + } + .onSuccess { _loggingStatus.value = it } + .onFailure { _lastError.value = "Couldn't read logging state: ${it.message}" } + _loggingBusy.value = false + } + } + + /** + * Stop any on-board logging, drop every flash entry, and remove every + * armed logger — a clean slate for the logging module. Destructive: + * un-downloaded data is gone. + * + * The status is refreshed afterward but LOG_LENGTH is set straight to + * zero locally rather than trusted from the board: the MMS reports a + * housekeeping sentinel (~1) for up to a minute after a clear. + */ + fun clearLogsAndLoggers() { + val device = device ?: return + viewModelScope.launch { + _loggingBusy.value = true + runCatching { + device.stopOnBoardLogging() + device.clearLog() + } + .onSuccess { + _statusMessage.value = "Logs and loggers cleared" + val loggers = runCatching { device.queryActiveLoggers() }.getOrDefault(emptyList()) + _loggingStatus.value = LoggingStatus(entryCount = 0, activeLoggers = loggers) + } + .onFailure { _lastError.value = "Clear failed: ${it.message}" } + _loggingBusy.value = false + } + } + fun isNameValid(name: String): Boolean = Settings.isNameValid(name) /** Write a new BLE advertising name (validated; ≤ 26 ASCII chars). */ diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..e398243 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..f81e273 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..f81e273 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..54b9f17 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7ac8aba Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..7cc90ce Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..a4b7cce Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..68f270b Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..6bddea4 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..5e15fd5 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..efbbaef Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..f768b70 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..3fd8fdb Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..41e21b2 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..abae944 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..1d2c8d5 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f4437ee Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..eea77a5 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..93209a8 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,5 @@ + + + + #FE9500 + diff --git a/app/src/test/kotlin/com/mbientlab/metawear/app/DemoBleTransportTest.kt b/app/src/test/kotlin/com/mbientlab/metawear/app/DemoBleTransportTest.kt index 53d4824..a4ffe21 100644 --- a/app/src/test/kotlin/com/mbientlab/metawear/app/DemoBleTransportTest.kt +++ b/app/src/test/kotlin/com/mbientlab/metawear/app/DemoBleTransportTest.kt @@ -38,8 +38,8 @@ import org.junit.jupiter.api.Test /** * End-to-end exercises of the demo emulator through the real - * [MetaWearDevice] protocol stack — the same path the app uses in demo mode, - * with no Android or Bluetooth anywhere. + * [MetaWearDevice] protocol stack — the same path the app takes against a + * physical board, with no Android or Bluetooth anywhere. */ class DemoBleTransportTest { diff --git a/app/src/main/kotlin/com/mbientlab/metawear/app/demo/DemoBleTransport.kt b/app/src/test/kotlin/com/mbientlab/metawear/app/demo/DemoBleTransport.kt similarity index 97% rename from app/src/main/kotlin/com/mbientlab/metawear/app/demo/DemoBleTransport.kt rename to app/src/test/kotlin/com/mbientlab/metawear/app/demo/DemoBleTransport.kt index c16faae..4102588 100644 --- a/app/src/main/kotlin/com/mbientlab/metawear/app/demo/DemoBleTransport.kt +++ b/app/src/test/kotlin/com/mbientlab/metawear/app/demo/DemoBleTransport.kt @@ -22,8 +22,9 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch /** - * A protocol-level MetaWear emulator for demos, emulator runs, and screenshots - * — no hardware, no Bluetooth. + * A protocol-level MetaWear emulator used as a JVM test fixture — no hardware, + * no Bluetooth. Lives in the test source set only; the shipping app never + * constructs it. * * Behaves like a connected MetaMotion S on firmware 1.7.3: it answers module * discovery, Device Information reads, battery/MAC/log reads, streams @@ -32,13 +33,13 @@ import kotlinx.coroutines.launch * emulates the logging round trip (trigger allocation, LOG_LENGTH growth * while "recording", paged readout with progress). * - * Pure Kotlin (no Android imports) so the whole demo pipeline is exercised by - * JVM unit tests through the real [com.mbientlab.metawear.MetaWearDevice]. + * Pure Kotlin (no Android imports) so the whole app device stack is exercised + * by JVM unit tests through the real [com.mbientlab.metawear.MetaWearDevice]. * * Each instance wears an [Identity] — [Identity.board] mints up to 16 * distinguishable boards (identifier, serial, MAC, waveform phase) so - * multi-board flows are exercisable with a simulated fleet; the default is - * the legacy single demo board. + * multi-board flows are testable with a simulated fleet; the default is the + * legacy single demo board. */ class DemoBleTransport( private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), @@ -47,8 +48,8 @@ class DemoBleTransport( companion object { /** - * Stable identifier so app-side code can recognise the demo device. - * Equal to `Identity.board(0).identifier` — the legacy single demo board. + * Stable identifier of the default board — equal to + * `Identity.board(0).identifier`, the legacy single demo board. */ const val DEVICE_IDENTIFIER: String = "DE:30:DE:30:DE:30" @@ -513,7 +514,7 @@ class DemoBleTransport( // any 2-chunk cartesian logger pair (accel/gyro/mag) plus every // single-chunk environmental logger — temperature (Int16 °C × 8), // humidity (UInt32 % × 1024), pressure (UInt32 Pa × 256) — so both - // streamed and polled logging round-trip in demo mode. + // streamed and polled logging round-trip through the emulator. class EnvProducer(val loggerId: Int, val rawData: (Double) -> Long) val cartesianPair: List diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d62b650..7ca45dd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -60,6 +60,9 @@ androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +# Full Material Symbols set — the app's screen iconography (sensor, logging, +# maintenance glyphs) goes well beyond the core icon subset material3 bundles. +androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } diff --git a/metawear-protocol/src/main/kotlin/com/mbientlab/metawear/DeviceLogging.kt b/metawear-protocol/src/main/kotlin/com/mbientlab/metawear/DeviceLogging.kt index 01bb820..655e002 100644 --- a/metawear-protocol/src/main/kotlin/com/mbientlab/metawear/DeviceLogging.kt +++ b/metawear-protocol/src/main/kotlin/com/mbientlab/metawear/DeviceLogging.kt @@ -819,6 +819,22 @@ suspend fun MetaWearDevice.stopOnBoardLogging() { writeRaw(Packet.command(Module.LOGGING, LOG_ENABLE, 0x00)) } +/** + * Stop on-board logging AND remove every armed logger, but keep the entries + * already in flash so they can still be downloaded (as a foreign log via + * anonymous signals, or after `recoverLoggers`). + * + * This is the "take the board out of a logging state you didn't start" + * action: [stopOnBoardLogging] alone leaves the loggers armed (the board + * still *reports* itself as logging), while [clearLog] also drops the data. + * The local logger registry is cleared to match the board. + */ +suspend fun MetaWearDevice.stopAndRemoveLoggers() { + writeRaw(Packet.command(Module.LOGGING, LOG_ENABLE, 0x00)) // stop logging + writeRaw(Packet.command(Module.LOGGING, LOG_REMOVE_ALL_TRIGGERS)) // remove all loggers, keep entries + loggerRegistry.clear() +} + /** * Flush the active logging page to flash so in-flight samples become readable. * diff --git a/metawear-protocol/src/test/kotlin/com/mbientlab/metawear/LoggingTest.kt b/metawear-protocol/src/test/kotlin/com/mbientlab/metawear/LoggingTest.kt index 699691a..fa5fdf2 100644 --- a/metawear-protocol/src/test/kotlin/com/mbientlab/metawear/LoggingTest.kt +++ b/metawear-protocol/src/test/kotlin/com/mbientlab/metawear/LoggingTest.kt @@ -500,6 +500,22 @@ class LoggingTest { assertTrue(after.none { it.size >= 2 && (it[0].toInt() and 0xFF) == 0x0B && (it[1].toInt() and 0xFF) == 0x10 }) } + // ---- stopAndRemoveLoggers ---- + + @Test + fun `stopAndRemoveLoggers disables logging and removes loggers but keeps entries`() = runTest { + val (device, transport) = connectedDevice() + transport.clearWrites() + device.stopAndRemoveLoggers() + + val cmds = transport.writtenCommands + assertArrayEquals(bytes(0x0B, 0x01, 0x00), cmds[0]) // logging disable + assertArrayEquals(bytes(0x0B, 0x0A), cmds[1]) // remove all loggers + assertEquals(2, cmds.size) + // Never the drop-entries command — data must survive. + assertTrue(cmds.none { it.size >= 2 && (it[0].toInt() and 0xFF) == 0x0B && (it[1].toInt() and 0xFF) == 0x09 }) + } + // ---- clearLog ---- @Test diff --git a/settings.gradle.kts b/settings.gradle.kts index 7cc53f5..6b18f6c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,6 +38,6 @@ include(":metawear-persistence") // (catalog client, bootloader interlock, DFU orchestration). include(":metawear-firmware") -// Step 5: Jetpack Compose demo app -// (scan, live stream, logging, sessions, controls, settings, firmware, demo mode). +// Step 5: Jetpack Compose MetaWear app +// (scan, live stream, logging, sessions, controls, settings, firmware). include(":app")