-
Notifications
You must be signed in to change notification settings - Fork 0
Extensible BLE signal source plugin system #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
8a09a8e
feat: Add ADS1299 EEG source provider and live signal monitoring
yelabb 743290e
feat: Add 'Signal Sources' destination to More sheet and make destina…
yelabb 828228a
feat(live_signal_screen): implement demo mode for synthetic signal ge…
yelabb 234e8df
feat: add unit tests for Ads1299Source and BLE source provider functi…
yelabb d7c0712
fix: add missing import for widgets in BLE source provider tests
yelabb File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,364 @@ | ||
| # Contributing a BLE Signal Source | ||
|
|
||
| BodyPress ships an **extensible plugin system** for BLE signal hardware. | ||
| Adding a new source requires **one Dart file + one registration line** — no changes to the framework, database, or UI code. | ||
|
|
||
| > **First source:** `Ads1299Source` (TI ADS1299 8-Ch EEG, EAREEG boards). | ||
| > Use it as a working reference throughout this guide. | ||
|
|
||
| --- | ||
|
|
||
| ## Table of Contents | ||
|
|
||
| 1. [Architecture overview](#architecture-overview) | ||
| 2. [Step-by-step guide](#step-by-step-guide) | ||
| 3. [The `BleSourceProvider` interface](#the-blesourceprovider-interface) | ||
| 4. [Starter template](#starter-template) | ||
| 5. [Parsing BLE notifications](#parsing-ble-notifications) | ||
| 6. [Lifecycle hooks](#lifecycle-hooks) | ||
| 7. [Registration](#registration) | ||
| 8. [Testing your source](#testing-your-source) | ||
| 9. [PR checklist](#pr-checklist) | ||
|
|
||
| --- | ||
|
|
||
| ## Architecture overview | ||
|
|
||
| ``` | ||
| ┌─────────────────────────┐ | ||
| │ SourceBrowserScreen │ Lists all registered sources | ||
| └──────────┬──────────────┘ | ||
| │ tap | ||
| ┌──────────▼──────────────┐ | ||
| │ LiveSignalScreen │ Scan → Pick → Connect → Stream | ||
| └──────────┬──────────────┘ | ||
| │ delegates to | ||
| ┌──────────▼──────────────┐ | ||
| │ BleSourceService │ Generic scan/connect/notify engine | ||
| └──────────┬──────────────┘ | ||
| │ calls provider.parseNotification() | ||
| ┌──────────▼──────────────┐ | ||
| │ BleSourceProvider │ YOUR CODE — parse bytes → SignalSample | ||
| └─────────────────────────┘ | ||
| ``` | ||
|
|
||
| **Key files:** | ||
|
|
||
| | File | Purpose | | ||
| | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | ||
| | `lib/core/services/ble_source_provider.dart` | Core abstractions: `BleSourceProvider`, `BleSourceRegistry`, `BleSourceService`, `SignalSample`, `ChannelDescriptor`, `SignalSession` | | ||
| | `lib/core/services/sources/ads1299_source.dart` | Reference implementation — ADS1299 8-Ch EEG | | ||
| | `lib/core/services/sources/source_registry_init.dart` | One-stop registration — add your source here | | ||
| | `lib/core/widgets/live_signal_chart.dart` | Multi-channel real-time waveform widget (auto-adapts to any channel count) | | ||
| | `lib/features/sources/screens/source_browser_screen.dart` | Source browser UI | | ||
| | `lib/features/sources/screens/live_signal_screen.dart` | Live scan + stream UI | | ||
|
|
||
| --- | ||
|
|
||
| ## Step-by-step guide | ||
|
|
||
| ### 1. Create your source file | ||
|
|
||
| ``` | ||
| lib/core/services/sources/my_device_source.dart | ||
| ``` | ||
|
|
||
| ### 2. Implement `BleSourceProvider` | ||
|
|
||
| Fill in **identity**, **BLE UUIDs**, **channel layout**, and **parsing** (see [interface reference](#the-blesourceprovider-interface) and [template](#starter-template) below). | ||
|
|
||
| ### 3. Register it | ||
|
|
||
| Open `lib/core/services/sources/source_registry_init.dart`: | ||
|
|
||
| ```dart | ||
| import 'my_device_source.dart'; | ||
|
|
||
| void registerAllSources(BleSourceRegistry registry) { | ||
| registry.register(Ads1299Source()); | ||
| registry.register(MyDeviceSource()); // ← add this | ||
| } | ||
| ``` | ||
|
|
||
| ### 4. Run & test | ||
|
|
||
| ```bash | ||
| flutter run # hot-reload picks up the new source instantly | ||
| flutter analyze # must stay at 0 errors / 0 warnings | ||
| ``` | ||
|
|
||
| Your source will appear in the **Source Browser** (`/sources`) and the full scan → connect → stream flow works out of the box. | ||
|
|
||
| --- | ||
|
|
||
| ## The `BleSourceProvider` interface | ||
|
|
||
| Every source **must** override these members: | ||
|
|
||
| ### Identity | ||
|
|
||
| | Member | Type | Example | | ||
| | ----------------- | -------------- | -------------------------------------------------------------------------------------------------- | | ||
| | `id` | `String` | `'ads1299'` — unique machine key | | ||
| | `displayName` | `String` | `'ADS1299 8-Ch EEG'` | | ||
| | `description` | `String` | One-liner for the source browser card | | ||
| | `icon` | `IconData` | `Icons.psychology_alt` | | ||
| | `advertisedNames` | `List<String>` | `['EAREEG']` — matched case-insensitively. Empty list = match any device exposing the service UUID | | ||
|
|
||
| ### BLE identifiers | ||
|
|
||
| | Member | Type | Example | | ||
| | -------------------------- | -------- | -------------------------------------------------------------- | | ||
| | `serviceUuid` | `String` | `'0000fe42-8e22-4541-9d4c-21edae82ed19'` | | ||
| | `notifyCharacteristicUuid` | `String` | Same UUID on single-characteristic designs, or a different one | | ||
|
|
||
| ### Channel layout | ||
|
|
||
| | Member | Type | Example | | ||
| | -------------------- | ------------------------- | ------------------------------------- | | ||
| | `channelDescriptors` | `List<ChannelDescriptor>` | 8 descriptors with label, unit, scale | | ||
| | `sampleRateHz` | `double` | `250` | | ||
|
|
||
| Each `ChannelDescriptor` has: | ||
|
|
||
| ```dart | ||
| ChannelDescriptor( | ||
| label: 'Fp1', // display label | ||
| unit: 'µV', // axis unit | ||
| defaultScale: 100, // ±range for autoscale (null = auto-detect) | ||
| ) | ||
| ``` | ||
|
|
||
| ### Data parsing | ||
|
|
||
| | Member | Signature | Notes | | ||
| | ------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------- | | ||
| | `parseNotification` | `SignalSample? parseNotification(List<int> data)` | Return `null` for malformed data. Must return exactly `channelCount` values. | | ||
|
|
||
| ### Lifecycle hooks (optional) | ||
|
|
||
| | Member | When called | Use case | | ||
| | ------------------------------- | ---------------------------------------- | -------------------------- | | ||
| | `onConnected(device, services)` | After GATT discovery, before subscribing | Send start/config commands | | ||
| | `onDisconnecting(device)` | Before disconnect | Send stop commands | | ||
|
|
||
| --- | ||
|
|
||
| ## Starter template | ||
|
|
||
| Copy this skeleton and fill in the `TODO` sections: | ||
|
|
||
| ```dart | ||
| import 'package:flutter/material.dart'; | ||
| import '../ble_source_provider.dart'; | ||
|
|
||
| class MyDeviceSource extends BleSourceProvider { | ||
| // ── Identity ────────────────────────────────────────────────────── | ||
| @override | ||
| String get id => 'my_device'; // TODO: unique id | ||
|
|
||
| @override | ||
| String get displayName => 'My Device Name'; // TODO: display name | ||
|
|
||
| @override | ||
| String get description => 'Short description.'; // TODO: description | ||
|
|
||
| @override | ||
| IconData get icon => Icons.sensors; // TODO: pick an icon | ||
|
|
||
| @override | ||
| List<String> get advertisedNames => ['MYDEV']; // TODO: BLE advertised names | ||
|
|
||
| // ── BLE identifiers ────────────────────────────────────────────── | ||
| @override | ||
| String get serviceUuid => 'TODO-SERVICE-UUID'; | ||
|
|
||
| @override | ||
| String get notifyCharacteristicUuid => 'TODO-NOTIFY-UUID'; | ||
|
|
||
| // ── Channel layout ─────────────────────────────────────────────── | ||
| @override | ||
| List<ChannelDescriptor> get channelDescriptors => [ | ||
| // TODO: one descriptor per channel | ||
| ChannelDescriptor(label: 'Ch 1', unit: 'µV', defaultScale: 100), | ||
| ChannelDescriptor(label: 'Ch 2', unit: 'µV', defaultScale: 100), | ||
| ]; | ||
|
|
||
| @override | ||
| double get sampleRateHz => 250; // TODO: actual sample rate | ||
|
|
||
| // ── Data parsing ───────────────────────────────────────────────── | ||
| @override | ||
| SignalSample? parseNotification(List<int> data) { | ||
| // TODO: Parse the raw BLE bytes into channel values. | ||
| // | ||
| // Tips: | ||
| // - Check data.length first, return null if too short | ||
| // - Read bytes in the order your hardware sends them | ||
| // - Convert to physical units (µV, mV, etc.) | ||
| // - Return a SignalSample with exactly channelCount values | ||
| // | ||
| // Example for 2-channel, 16-bit little-endian: | ||
| if (data.length < 4) return null; | ||
|
|
||
| final ch1 = (data[1] << 8 | data[0]).toSigned(16).toDouble(); | ||
| final ch2 = (data[3] << 8 | data[2]).toSigned(16).toDouble(); | ||
|
|
||
| return SignalSample( | ||
| time: DateTime.now(), | ||
| channels: [ch1, ch2], | ||
| ); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Parsing BLE notifications | ||
|
|
||
| ### Common byte layouts | ||
|
|
||
| **24-bit big-endian two's complement** (ADS1299): | ||
|
|
||
| ```dart | ||
| int raw = (data[offset] << 16) | (data[offset + 1] << 8) | data[offset + 2]; | ||
| if (raw >= 0x800000) raw -= 0x1000000; // sign extension | ||
| double uv = 1000000.0 * vRef * (raw / 16777215); | ||
| ``` | ||
|
|
||
| **16-bit little-endian signed** (many IMU / PPG boards): | ||
|
|
||
| ```dart | ||
| int raw = (data[offset + 1] << 8) | data[offset]; | ||
| double value = raw.toSigned(16).toDouble(); | ||
| ``` | ||
|
|
||
| **12-bit packed** (some ADCs): | ||
|
|
||
| ```dart | ||
| int raw = ((data[offset] & 0x0F) << 8) | data[offset + 1]; | ||
| if (raw >= 0x800) raw -= 0x1000; | ||
| ``` | ||
|
|
||
| ### Tips | ||
|
|
||
| - Always validate `data.length` before indexing. | ||
| - If notifications can carry multiple samples per packet, emit them in a loop. | ||
| - Round to reasonable precision: `double.parse(value.toStringAsFixed(2))`. | ||
| - The `SignalSample.time` should normally be `DateTime.now()` — the system timestamps on reception. If the device includes a sequence counter, you can reconstruct hardware-precise times. | ||
|
|
||
| --- | ||
|
|
||
| ## Lifecycle hooks | ||
|
|
||
| Some devices need a BLE write command to start streaming. Override `onConnected`: | ||
|
|
||
| ```dart | ||
| @override | ||
| Future<void> onConnected( | ||
| BluetoothDevice device, | ||
| List<BluetoothService> services, | ||
| ) async { | ||
| // Find the write characteristic | ||
| final svc = services.firstWhere( | ||
| (s) => s.uuid.str128.toLowerCase() == serviceUuid.toLowerCase(), | ||
| ); | ||
| final writeChar = svc.characteristics.firstWhere( | ||
| (c) => c.uuid.str128.toLowerCase() == _writeUuid.toLowerCase(), | ||
| ); | ||
|
|
||
| // Send a "start streaming" command | ||
| await writeChar.write([0x01], withoutResponse: true); | ||
| } | ||
| ``` | ||
|
|
||
| Similarly `onDisconnecting` for a "stop streaming" command. | ||
|
|
||
| --- | ||
|
|
||
| ## Registration | ||
|
|
||
| The file `source_registry_init.dart` is the **single entry point** where all sources are wired in: | ||
|
|
||
| ```dart | ||
| import '../ble_source_provider.dart'; | ||
| import 'ads1299_source.dart'; | ||
| import 'my_device_source.dart'; | ||
|
|
||
| void registerAllSources(BleSourceRegistry registry) { | ||
| registry.register(Ads1299Source()); | ||
| registry.register(MyDeviceSource()); | ||
| } | ||
| ``` | ||
|
|
||
| This function runs once at startup via the Riverpod `bleSourceRegistryProvider`. | ||
| No other file needs changes — the **Source Browser**, **Live Signal** screen, and **`SignalSession`** persistence all work generically with any registered provider. | ||
|
|
||
| --- | ||
|
|
||
| ## Testing your source | ||
|
|
||
| ### Unit-test `parseNotification` | ||
|
|
||
| ```dart | ||
| import 'package:flutter_test/flutter_test.dart'; | ||
| import 'package:bodypress_flutter/core/services/sources/my_device_source.dart'; | ||
|
|
||
| void main() { | ||
| final source = MyDeviceSource(); | ||
|
|
||
| test('parses valid notification', () { | ||
| final data = [0x00, 0x80, 0xFF, 0x7F]; // example bytes | ||
| final sample = source.parseNotification(data); | ||
| expect(sample, isNotNull); | ||
| expect(sample!.channels.length, source.channelCount); | ||
| }); | ||
|
|
||
| test('returns null for short data', () { | ||
| expect(source.parseNotification([0x01]), isNull); | ||
| }); | ||
|
|
||
| test('channel descriptors match expected count', () { | ||
| expect(source.channelDescriptors.length, source.channelCount); | ||
| }); | ||
| } | ||
| ``` | ||
|
|
||
| ### Integration testing on device | ||
|
|
||
| 1. Install on a physical Android/iOS device (BLE is not available on emulators). | ||
| 2. Open **Source Browser** → tap your source → scan → pick device → connect. | ||
| 3. Verify: | ||
| - All channels display in the live chart | ||
| - Values are in the expected physical range | ||
| - Disconnecting cleanly returns to idle state | ||
| - Recording captures samples and stores a `SignalSession` | ||
|
|
||
| ### BLE emulator option | ||
|
|
||
| For development without hardware, consider [ble_peripheral](https://pub.dev/packages/ble_peripheral) or [nRF Connect](https://www.nordicsemi.com/Products/Development-tools/nRF-Connect-for-mobile) on a second phone to broadcast synthetic notifications. | ||
|
|
||
| --- | ||
|
|
||
| ## PR checklist | ||
|
|
||
| Before submitting your pull request: | ||
|
|
||
| - [ ] Source file created in `lib/core/services/sources/` | ||
| - [ ] Extends `BleSourceProvider` with all required overrides | ||
| - [ ] Registered in `source_registry_init.dart` | ||
| - [ ] `flutter analyze` reports 0 errors / 0 warnings on your files | ||
| - [ ] `parseNotification` handles malformed/short data gracefully (returns `null`) | ||
| - [ ] Channel count in `channelDescriptors` matches `parseNotification` output | ||
| - [ ] Unit tests for `parseNotification` (valid data + edge cases) | ||
| - [ ] Tested on real hardware (or documented BLE emulator setup) | ||
| - [ ] Header doc-comment on the class with protocol summary (BLE UUIDs, byte format, voltage formula) | ||
| - [ ] No unrelated changes | ||
|
|
||
| --- | ||
|
|
||
| ## Questions? | ||
|
|
||
| Open an issue or discussion on the repository — we'll help you get your source working. | ||
|
|
||
| Happy hacking! 🧠⚡ | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The guide says
defaultScale: nullmeans “auto-detect”, but the currentLiveSignalChartimplementation falls back to a fixed100whendefaultScaleis null. Either update the guide to match the behavior, or implement true auto-scaling whendefaultScaleis omitted.