Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 63 additions & 5 deletions lib/ble/adapters/_registry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ import 'signals.dart';
const String kHeartRateServiceUuid = '0000180d-0000-1000-8000-00805f9b34fb';
const String kHeartRateMeasurementUuid = '00002a37-0000-1000-8000-00805f9b34fb';

/// Device Information Service's System ID characteristic — an 8-byte EUI-64.
/// Standard GATT, not band-specific; RingConn is the first entry that needs
/// to read it (its own BLE MAC has to come from somewhere, and there is no
/// vendor server to ask — see `ringconn.dart`'s `ringConnMacFromSystemId`).
const String kSystemIdUuid = '00002a23-0000-1000-8000-00805f9b34fb';

/// The Fossil/Skagen Q Hybrid's GATT service. NOT the encrypted Hybrid HR /
/// Gen 6 sibling, which advertises this same UUID — see [kQHybrid]'s own doc.
const String kQHybridService = '3dda0001-957f-7d4a-34a6-74696673696d';
Expand Down Expand Up @@ -97,6 +103,17 @@ const String kCasioAllFeaturesChar = '26eb002d-b012-49a8-b1f8-394fb2032b0f';
/// The Oura ring's GATT service, identical across the generations seen so far.
const String kOuraService = '98ed0001-a541-11e4-b6a0-0002a5d5c51b';

/// RingConn's one data service — Gen 2, Gen 2 Air and Gen 3 all speak the
/// identical service, characteristics, framing and opcode set.
const String kRingConnService = '8327ad99-2d87-4a22-a8ce-6dd7971c0437';

/// Host to ring. Every RingConn command is written here, with response.
const String kRingConnCommandChar = '8327ad98-2d87-4a22-a8ce-6dd7971c0437';

/// Ring to host. Every status reply and every history page share this one
/// characteristic — there is no separate data pipe.
const String kRingConnNotifyChar = '8327ad97-2d87-4a22-a8ce-6dd7971c0437';

/// Host to ring. Every Oura command is written here, with response.
const String kOuraCommandChar = '98ed0002-a541-11e4-b6a0-0002a5d5c51b';

Expand Down Expand Up @@ -543,6 +560,45 @@ const BandEntry kOura = BandEntry.notify(
timeAnchor: TimeAnchor.arrival,
);

/// RingConn (Gen 2, Gen 2 Air, Gen 3) — one service, a challenge-response
/// handshake keyed by the ring's own BLE MAC, and two independent history
/// channels the ring resumes on its own (this build persists no cursor for
/// either — see `ringconn.dart` and `ringconn_link.dart`).
///
/// A REAL, UNRESOLVED RISK: the ring is not reliably known to advertise
/// [kRingConnService] in its foreground scan advertisement — only its name
/// (`RingConn Gen2-XXXX` etc). `nameMatcher` is left null here rather than
/// guessed at: if the service prefix alone turns out not to find the ring on
/// a live scan, this is where a name fallback belongs (see
/// [BandEntry.framed]'s own use of one for WHOOP 4's own unreliable service
/// ad), not something to wire blind against a ring nobody here owns.
///
/// [kSystemIdUuid] is listed as required rather than left to a plain GATT
/// read against "whatever the peripheral happens to expose": the handshake's
/// MAC recovery has nothing to fall back to without it.
///
/// EXPERIMENTAL, and it stays that way: nobody on this project owns a ring,
/// so not a byte of this path has met hardware (ASSUMPTIONS R6).
/// [TimeAnchor.arrival] is the conservative default for a band that decodes
/// nothing into a timestamped sample yet — see `RingConnAdapter.signals`.
///
/// It is `pick: null` in `kPairableSensors` even so: `RingConnAdapter.signals`
/// is `const {}`, so `HrsLink.deriveTier` (which looks up `declaredSignals`
/// for the pairing `adapter_id`) resolves this band's tier to null rather
/// than inheriting [kBleHrs]'s `'beatToBeat'` — same reasoning as Oura's own
/// pairing, same reason a wrong tier here is silent.
const BandEntry kRingConn = BandEntry.notify(
id: 'ringconn',
label: 'RingConn',
service: kRingConnService,
characteristics: <String>[
kRingConnCommandChar,
kRingConnNotifyChar,
kSystemIdUuid,
],
timeAnchor: TimeAnchor.arrival,
);

/// DT78 / DT92 / DT66 and the wider tail of WearFit-2.0-compatible OEM clones
/// that share this exact service — one Nordic UART instance, no envelope, no
/// checksum, no auth (`dt78.dart`'s own header has the worked byte examples).
Expand Down Expand Up @@ -747,6 +803,7 @@ const List<BandEntry> kBandRegistry = <BandEntry>[
kWhoopGen5,
kBleHrs,
kOura,
kRingConn,
kDt78,
kLefun,
kHPlus,
Expand Down Expand Up @@ -783,11 +840,11 @@ BandEntry bandEntryFor(BandProfile wire) =>
/// maps straight to the declared signals instead of a constructed instance —
/// KEPT IN SYNC BY HAND with `whoop_gen4.dart`'s `kWhoopGen4Signals`,
/// `ble_hrs.dart`'s `BleHrsAdapter.signals`, `oura.dart`'s
/// `OuraAdapter.signals`, `dt78.dart`'s `Dt78Adapter.signals` and
/// `pinetime.dart`'s `PineTimeAdapter.signals`, since importing those back
/// into this file (each of which already imports THIS file for its
/// `BandEntry`) would be a needless import cycle for a few
/// lines of data.
/// `OuraAdapter.signals`, `ringconn.dart`'s `RingConnAdapter.signals`,
/// `dt78.dart`'s `Dt78Adapter.signals` and `pinetime.dart`'s
/// `PineTimeAdapter.signals`, since importing those back into this file (each
/// of which already imports THIS file for its `BandEntry`) would be a
/// needless import cycle for a handful of lines of data.
///
/// gen5 reuses gen4's map: `kWhoopGen5`'s own doc comment states "same inner
/// payload layout as gen4 — only the envelope differs", so gen4's declared
Expand All @@ -813,6 +870,7 @@ const Map<String, Map<InputSignal, Duration>> kAdapterSignals =
InputSignal.rrIntervals: Duration(seconds: 1),
},
'oura': <InputSignal, Duration>{},
'ringconn': <InputSignal, Duration>{},
'dt78': <InputSignal, Duration>{},
'lefun': <InputSignal, Duration>{},
'hplus': <InputSignal, Duration>{},
Expand Down
19 changes: 19 additions & 0 deletions lib/ble/adapters/adapter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ abstract class BandLink {
/// [BandEntry.requiredCharacteristics], where the connect aborts loudly.
Stream<(int atSec, List<int> value)> notify(String characteristicUuid);

/// Read a characteristic once. Returns null on a missing characteristic, a
/// dead link, or a timeout — the same failure vocabulary [write] uses.
///
/// The whole surface used to be `notify`/`write`/`log`, and every band so
/// far only ever needed to be WRITTEN to or NOTIFIED by. RingConn's auth
/// needs one plain GATT read (the standard System ID characteristic, to
/// recover its own BLE MAC) that no framed band and no notify-only sensor
/// before it required — see `ringconn.dart`.
Future<List<int>?> read(String characteristicUuid);

/// Write with response, which is also what triggers bonding. Returns whether
/// the GATT write was confirmed; false covers a missing characteristic, a
/// dead link, a timeout and a refused opcode alike.
Expand Down Expand Up @@ -310,6 +320,15 @@ class ReplayBandLink implements BandLink {
Stream<(int, List<int>)> notify(String characteristicUuid) =>
_channel(characteristicUuid).stream;

/// What [read] answers, by characteristic uuid. A test sets this before
/// exercising the adapter; an uuid with no entry answers null, same as a
/// real link's missing-characteristic case.
final Map<String, List<int>> readValues = {};

@override
Future<List<int>?> read(String characteristicUuid) async =>
readValues[characteristicUuid];

@override
Future<bool> write(String characteristicUuid, List<int> value) async {
writes.add((characteristicUuid, value));
Expand Down
23 changes: 23 additions & 0 deletions lib/ble/adapters/gatt_link.dart
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,29 @@ class GattBandLink implements BandLink {
);
}

@override
Future<List<int>?> read(String characteristicUuid) async {
if (_closed) {
log('read skipped: it belongs to a link that is no longer live.');
return null;
}
final c = _find(characteristicUuid);
if (c == null) {
log('read: no characteristic ${characteristicUuid.substring(0, 8)} on '
'this peripheral.');
return null;
}
try {
return await c.read().timeout(_notifyTimeout);
} on TimeoutException {
log('read timeout: no GATT response in ${_notifyTimeout.inSeconds}s.');
return null;
} catch (e) {
log('read error: $e');
return null;
}
}

@override
Future<bool> write(String characteristicUuid, List<int> value) {
// THE DANGEROUS-OPCODE BLOCK, at the one place every adapter's writes
Expand Down
Loading
Loading