diff --git a/lib/ble/adapters/_registry.dart b/lib/ble/adapters/_registry.dart index 284cdeaf5..059c73e09 100644 --- a/lib/ble/adapters/_registry.dart +++ b/lib/ble/adapters/_registry.dart @@ -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'; @@ -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'; @@ -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: [ + 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). @@ -747,6 +803,7 @@ const List kBandRegistry = [ kWhoopGen5, kBleHrs, kOura, + kRingConn, kDt78, kLefun, kHPlus, @@ -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 @@ -813,6 +870,7 @@ const Map> kAdapterSignals = InputSignal.rrIntervals: Duration(seconds: 1), }, 'oura': {}, + 'ringconn': {}, 'dt78': {}, 'lefun': {}, 'hplus': {}, diff --git a/lib/ble/adapters/adapter.dart b/lib/ble/adapters/adapter.dart index 4aff44b87..0d071357f 100644 --- a/lib/ble/adapters/adapter.dart +++ b/lib/ble/adapters/adapter.dart @@ -70,6 +70,16 @@ abstract class BandLink { /// [BandEntry.requiredCharacteristics], where the connect aborts loudly. Stream<(int atSec, List 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?> 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. @@ -310,6 +320,15 @@ class ReplayBandLink implements BandLink { Stream<(int, List)> 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> readValues = {}; + + @override + Future?> read(String characteristicUuid) async => + readValues[characteristicUuid]; + @override Future write(String characteristicUuid, List value) async { writes.add((characteristicUuid, value)); diff --git a/lib/ble/adapters/gatt_link.dart b/lib/ble/adapters/gatt_link.dart index 7e24b7840..d921e5b3d 100644 --- a/lib/ble/adapters/gatt_link.dart +++ b/lib/ble/adapters/gatt_link.dart @@ -131,6 +131,29 @@ class GattBandLink implements BandLink { ); } + @override + Future?> 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 write(String characteristicUuid, List value) { // THE DANGEROUS-OPCODE BLOCK, at the one place every adapter's writes diff --git a/lib/ble/adapters/ringconn.dart b/lib/ble/adapters/ringconn.dart new file mode 100644 index 000000000..3ea886fad --- /dev/null +++ b/lib/ble/adapters/ringconn.dart @@ -0,0 +1,356 @@ +// RingConn (Gen 2 / Gen 2 Air / Gen 3) as a [BandAdapter]: authenticate +// against the ring's own BLE MAC, drain both history channels, bank every +// frame verbatim, decode nothing. +// +// NOTHING HERE HAS MET HARDWARE. Nobody on this project owns a ring (owner +// ruling R6), so not one byte of this path has been exercised against one. It +// ships EXPERIMENTAL, `signals` stays `const {}`, and nothing this file writes +// becomes a number: its rows carry a non-null `source`, and every derive/export +// read filters `source IS NULL`. That is correct behaviour for an uncalibrated +// decoder, not a limitation to route around. +// +// THE SHAPE IS SIMPLER THAN OURA'S, and for a real reason rather than an +// oversight: the ring tracks its own resume position per channel, so unlike +// `OuraAdapter` this file holds no pairing key, no drain cursor and no time +// anchor. Every connection opens both channels at "now" — that is the same +// behaviour the ring's own vendor app exercises, and it is exactly why there +// is a known limitation to state rather than hide: the ring appears to share +// ONE resume pointer per channel across every bonded client, so whichever +// side (us or the vendor app) opens at "now" first drains the backlog and +// leaves the other side an empty pass. Nothing is lost permanently — the +// ring's own buffer is measured in days — but a sync run right after the +// vendor app's own sync can come back looking emptier than expected. +// +// THE AUTH HANDSHAKE IS A KEYED HASH, NOT VENDOR ENCRYPTION. SM3 is a +// published algorithm (`ringconn.dart` in the protocol package) computed over +// the ring's own BLE MAC — read openly off the standard System ID +// characteristic — and a single-byte challenge. There is no vendor secret, no +// cloud key and nothing installed on the ring; the whole handshake runs fresh +// on every connection with nothing persisted host-side. +// +// WHAT STAYS EXPERIMENTAL, AND WHY IT IS NOT A GAP TO CLOSE HERE: the bulk +// pages this file drains (`0x47` PPG/optical, `0x4c` activity/sleep) carry +// per-field layouts — HR, HRV, SpO2, respiratory rate, step counts — that are +// well-characterised on paper but unverified on real hardware. `run()` reads +// only the STRUCTURAL envelope: the reply tag, the page's remaining-count +// byte, and the fixed record length needed to slice a page and walk the +// stream — never a byte inside a record. Every record, decoded or not, is +// archived verbatim; a future decoder finds them there once someone owns a +// ring to check one against. + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +import '_registry.dart'; +import 'adapter.dart'; +import 'signals.dart'; + +/// One RingConn session. Not const, unlike [BleHrsAdapter]: [nowSeconds] is +/// injected so a fixture replay is deterministic — the drain cursor this file +/// opens both channels at is derived from wall-clock now, and `DateTime.now()` +/// must not appear directly in an adapter body (see [BandLink]'s own doc). +class RingConnAdapter extends BandAdapter { + /// Wall-clock now, in Unix seconds. + final int Function() nowSeconds; + + /// How long to wait for a reply the ring owes us. + final Duration replyTimeout; + + RingConnAdapter({ + int Function()? nowSeconds, + this.replyTimeout = const Duration(seconds: 5), + }) : nowSeconds = + nowSeconds ?? (() => DateTime.now().millisecondsSinceEpoch ~/ 1000); + + @override + BandEntry get entry => kRingConn; + + /// NOTHING. See this file's own header — every per-field layout this ring's + /// bulk pages carry is unverified against real hardware, so nothing is + /// claimed until a decoder exists and a real capture has met it. + @override + Map get signals => const {}; + + /// A hard ceiling on pages within ONE burst. `remaining` is a single byte, + /// so a well-behaved burst needs nowhere near this many ACKs to reach + /// zero — this only catches a ring that never does. There is no trusted + /// "channel is done" signal on this wire at all (only a low-confidence flag + /// on the sync-open reply), so this cap and [replyTimeout] are the whole of + /// what bounds the loop below — never a semantic end. + static const int _kMaxPagesPerBurst = 512; + + @override + Stream run(BandLink link) async* { + final inbox = _Inbox(); + final sub = link.notify(kRingConnNotifyChar).listen( + (rec) { + final f = parseRingConnFrame(rec.$2); + if (f != null) inbox.add(f, Uint8List.fromList(rec.$2)); + }, + onDone: inbox.close, + onError: (Object _) => inbox.close(), + ); + try { + final mac = await _readMac(link); + if (mac == null) { + link.log('ringconn: could not read the System ID; no MAC, no auth.'); + return; + } + final authArchive = []; + final authed = await _authenticate(link, inbox, mac, authArchive); + if (authArchive.isNotEmpty) { + yield SampleBatch(const [], raw: authArchive); + } + if (!authed) return; + + // Two independent channels, each with its own resume pointer on the + // ring itself — drained one after the other, same command shape with + // one byte differing (see `ringConnCmdSyncOpen`'s own doc). + yield* _drainChannel(link, inbox, kRingConnChannelSleep); + // A frame the ring resent after the sleep channel's own loop already + // gave up waiting on it (a late ack reply, say) would otherwise sit in + // the inbox and get matched as the AWAKE channel's sync-open reply — + // there is no channel tag on that reply to tell the two apart. Still + // archived, never silently dropped (owner rulings R1-R3); just not + // read as belonging to the channel about to open. + final stale = inbox.drainStale(); + if (stale.isNotEmpty) yield SampleBatch(const [], raw: stale); + yield* _drainChannel(link, inbox, kRingConnChannelAwake); + } finally { + await sub.cancel(); + } + } + + /// Read the System ID once and recover the ring's own BLE MAC from it, or + /// null when the characteristic is missing, unreadable, or too short to be + /// one. + Future _readMac(BandLink link) async { + final raw = await link.read(kSystemIdUuid); + if (raw == null || raw.length != 8) return null; + return ringConnMacFromSystemId(raw); + } + + /// Status, challenge, SM3, answer. False on any refusal — a session that + /// carries on unauthenticated gets no further replies at all, which looks + /// identical to a dead link. + /// + /// Every frame walked past — matched or not — is appended to [archive], so + /// a failed handshake still banks whatever the ring actually said (owner + /// rulings R1-R3: capture everything). + Future _authenticate( + BandLink link, + _Inbox inbox, + List mac, + List archive, + ) async { + if (!await link.write(kRingConnCommandChar, ringConnCmdStatus())) { + return false; + } + final challengeFrame = await inbox.firstWhere( + (f) => + f.respid == kRingConnRespAuth && + f.payload.length >= 2 && + f.payload[0] == 0x00, + replyTimeout, + onEach: archive.add, + ); + if (challengeFrame == null) { + link.log('ringconn: no authentication challenge.'); + return false; + } + final response = ringConnAuthResponse(mac, challengeFrame.payload[1]); + if (!await link.write( + kRingConnCommandChar, + ringConnCmdAuthResponse(response), + )) { + return false; + } + final confirm = await inbox.firstWhere( + (f) => + f.respid == kRingConnRespAuth && + f.payload.isNotEmpty && + f.payload[0] == 0x01, + replyTimeout, + onEach: archive.add, + ); + if (confirm == null) { + link.log('ringconn: authentication was not confirmed.'); + return false; + } + return true; + } + + /// Open [channel] at "now" and drain the one burst it hands back. + /// + /// OPENS AT "NOW", NEVER AT A STORED CURSOR — this file holds none. See this + /// file's own header on why that is the app-faithful choice and what it + /// costs when the vendor app synced first. + Stream _drainChannel( + BandLink link, + _Inbox inbox, + int channel, + ) async* { + final cursor = ringConnCursor(nowSeconds()); + if (!await link.write( + kRingConnCommandChar, + ringConnCmdSyncOpen(cursor, channel), + )) { + link.log('ringconn: sync-open refused on channel $channel; skipping ' + 'it.'); + return; + } + final syncOpenRaw = []; + final opened = await inbox.firstWhere( + (f) => f.respid == kRingConnRespSyncOpen, + replyTimeout, + onEach: syncOpenRaw.add, + ); + for (final b in syncOpenRaw) { + yield SampleBatch(const [], raw: [b]); + } + if (opened == null) { + link.log('ringconn: no sync-open reply on channel $channel.'); + return; + } + if (!await link.write(kRingConnCommandChar, ringConnCmdFetch())) { + link.log('ringconn: fetch refused on channel $channel; ending its ' + 'drain.'); + return; + } + + var page = 0; + for (; page < _kMaxPagesPerBurst; page++) { + final rec = await inbox.next(replyTimeout); + if (rec == null) { + link.log('ringconn: no reply within the timeout on channel ' + '$channel.'); + break; + } + final (f, bytes) = rec; + // Yielded BEFORE the ack below, not accumulated for one yield at the + // end of the whole burst: the ack is what advances the ring's own + // resume pointer (this host keeps none of its own — see the class + // doc), so a page must reach the host's buffer before that pointer + // moves. `raw_archive`'s primary key is (device_id, hex), so a page + // that happens to reach the host again some other way is a no-op, not + // a duplicate. + yield SampleBatch(const [], raw: [bytes]); + if (ringConnIsBulk(f.respid)) { + final parsed = parseRingConnBulkPage(f); + // A page that will not slice cleanly is treated the same as the last + // page of a burst: there is nothing safe left to do with it, and the + // bytes are archived above either way. + if (parsed == null || parsed.remaining == 0) break; + final ack = f.respid == kRingConnRespBulkPpg + ? ringConnCmdAckPpg() + : ringConnCmdAckActivity(); + // THIS IS THE `trim-on-ack` ROW OF `OffloadCheckpoint`'s OWN TABLE, + // NOT A PLAIN WRITE: the ack above is what moves the ring's resume + // pointer past this page, so it must not reach the ring until the + // page just yielded is durably committed — a crash between an + // un-gated write and the periodic flush would lose a page the ring + // will never redeliver. `confirm` is the write itself, called by the + // host only after `_commit`, same contract `oura.dart` documents. + final acked = Completer(); + yield OffloadCheckpoint(() async { + final ok = await link.write(kRingConnCommandChar, ack); + if (!acked.isCompleted) acked.complete(ok); + return ok; + }); + if (!await acked.future.timeout(replyTimeout, onTimeout: () => false)) { + link.log('ringconn: page ack refused on channel $channel; ending ' + 'its drain.'); + break; + } + continue; + } + if (ringConnEndsBurst(f.respid)) break; + // Anything else is archived above and otherwise ignored — the loop + // keeps waiting for a frame that IS one of the two. + } + // The only exit that is NOT one of the logged breaks above: the loop ran + // out its full range without the ring ever signalling the burst was + // done. Every byte received is still archived above — this is a log + // line about an unusually long burst, not a data-loss report — but it + // needs to say so, the same as every other early-exit path here does. + if (page == _kMaxPagesPerBurst) { + link.log('ringconn: hit the $_kMaxPagesPerBurst-page cap on channel ' + '$channel with no burst-end reply; stopping this session\'s ' + 'drain there.'); + } + } +} + +/// The single instance. Not const — see the class doc — but cheap to build: +/// nothing here is session state until [BandAdapter.run] is called. +final RingConnAdapter kRingConnAdapter = RingConnAdapter(); + +/// Frames off the notify characteristic, buffered so a reply landing before +/// anyone is waiting is not dropped. The same shape as `oura.dart`'s private +/// `_Inbox`, and not shared with it for the same reason `oura_link.dart`'s +/// pairing flow keeps its own tiny copy rather than generalising three call +/// sites' worth of "the next frame, or nothing" into shared plumbing. +class _Inbox { + final List<(RingConnFrame, Uint8List)> _buf = []; + Completer<(RingConnFrame, Uint8List)?>? _waiter; + bool _closed = false; + + void add(RingConnFrame f, Uint8List raw) { + final w = _waiter; + if (w != null && !w.isCompleted) { + _waiter = null; + w.complete((f, raw)); + return; + } + _buf.add((f, raw)); + } + + void close() { + _closed = true; + final w = _waiter; + _waiter = null; + if (w != null && !w.isCompleted) w.complete(null); + } + + /// Everything still buffered and unconsumed, cleared. Called between the + /// two channels — see `run()`'s own comment on why a straggler must not + /// carry over. + List drainStale() { + final bytes = [for (final (_, b) in _buf) b]; + _buf.clear(); + return bytes; + } + + /// The next frame, or null on timeout or a closed link. + Future<(RingConnFrame, Uint8List)?> next(Duration timeout) { + if (_buf.isNotEmpty) return Future.value(_buf.removeAt(0)); + if (_closed) return Future.value(null); + final w = Completer<(RingConnFrame, Uint8List)?>(); + _waiter = w; + return w.future.timeout(timeout, onTimeout: () { + if (identical(_waiter, w)) _waiter = null; + return null; + }); + } + + /// The next frame satisfying [test], discarding what comes before it. + /// [onEach] is called for every frame walked past — matched or not — so a + /// caller can archive the bytes even when they were not the one it was + /// waiting for. [timeout] bounds the whole search, not each frame. + Future firstWhere( + bool Function(RingConnFrame) test, + Duration timeout, { + void Function(Uint8List raw)? onEach, + }) async { + final deadline = Stopwatch()..start(); + while (deadline.elapsed < timeout) { + final rec = await next(timeout - deadline.elapsed); + if (rec == null) return null; + onEach?.call(rec.$2); + if (test(rec.$1)) return rec.$1; + } + return null; + } +} diff --git a/lib/ble/hrs_link.dart b/lib/ble/hrs_link.dart index 7af1d437e..84b515611 100644 --- a/lib/ble/hrs_link.dart +++ b/lib/ble/hrs_link.dart @@ -70,6 +70,7 @@ import 'hplus_link.dart' show HPlusLink; import 'lefun_link.dart' show LefunLink; import 'oura_link.dart' show OuraLink; import 'qhybrid_link.dart' show QHybridLink; +import 'ringconn_link.dart' show RingConnLink; export 'adapters/host.dart' show HrsReading; @@ -613,11 +614,15 @@ class HrsLink { /// DISPATCHES ON `adapter_id` BEFORE TOUCHING ANYTHING. An Oura row carries /// a secret this class knows nothing about — [OuraLink.forgetRing] drops the /// stored key and the row together, and calling `disarm()` on it here would - /// leave that key behind while looking like a complete forget. An HPlus row - /// has no secret, but its live connection is [HPlusLink.instance], a - /// separate singleton from this class's own chest-strap session — - /// `disarm()` here would tear down the wrong link and leave the real one - /// (and its `BandHost`'s flush timer) running against a deleted device id. + /// leave that key behind while looking like a complete forget. A RingConn + /// row carries no such secret, but still needs [RingConnLink.forgetRing] + /// rather than this class's own `disarm()` — that call tears down a live + /// WORKOUT sensor session, not a RingConn `sync()` that may be mid-drain. + /// An HPlus row similarly has no secret, but its live connection is + /// [HPlusLink.instance], a separate singleton from this class's own + /// chest-strap session — `disarm()` here would tear down the wrong link and + /// leave the real one (and its `BandHost`'s flush timer) running against a + /// deleted device id. static Future forgetDevice(String id) async { if (id == LocalDb.kPrimaryDeviceId) { debugPrint('[hrs] refusing to forget the primary band from here.'); @@ -630,6 +635,10 @@ class HrsLink { await OuraLink.forgetRing(id); return; } + if (row?['adapter_id'] == kRingConn.id) { + await RingConnLink.forgetRing(id); + return; + } if (row?['adapter_id'] == kLefun.id) { // No secret to drop — the envelope this device speaks has no key // exchange — so this is a plain stop-and-delete, same shape as Oura's diff --git a/lib/ble/ringconn_link.dart b/lib/ble/ringconn_link.dart new file mode 100644 index 000000000..c7c03081c --- /dev/null +++ b/lib/ble/ringconn_link.dart @@ -0,0 +1,256 @@ +// The HOST for a paired RingConn ring: connect by stored `remote_id`, +// discover, drive [RingConnAdapter] over the link, bank what comes back, +// disconnect. +// +// NOTHING HERE HAS MET HARDWARE. Nobody on this project owns a ring (owner +// ruling R6), so not one byte of this path has been exercised against one. +// `kRingConn` stays EXPERIMENTAL, `RingConnAdapter.signals` stays `const {}`, +// and nothing this file writes becomes a number: its rows carry a non-null +// `source`, and every derive/export read filters `source IS NULL`. +// +// SIMPLER THAN `oura_link.dart`, FOR A REAL REASON. There is no pairing key to +// mint or store in the keychain (the handshake is a keyed hash over the +// ring's own MAC, read openly off a standard characteristic — see +// `ringconn.dart`), and no `sync_cursor`/`sync_anchor` to persist: the ring +// tracks its own resume position per channel, so this host always opens both +// channels at "now" rather than resuming a bookmark. One-shot [sync] — +// connect, drain, disconnect — same shape as `OuraLink.sync`. + +import 'dart:async'; + +import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +import '../data/db.dart'; +import '../data/models.dart' show ArchiveRecord; +import 'adapters/_registry.dart'; +import 'adapters/adapter.dart'; +import 'adapters/gatt_link.dart'; +import 'adapters/host.dart' show BandHost; +import 'adapters/ringconn.dart'; +import 'ble_state.dart' show withSecondaryLinkSlot; + +String _hex(List b) => + b.map((x) => x.toRadixString(16).padLeft(2, '0')).join(); + +/// The live link to a paired RingConn ring. One instance; a second concurrent +/// ring is not a thing anyone asked for. +class RingConnLink { + RingConnLink._(); + static final RingConnLink instance = RingConnLink._(); + + /// The `device` row for the paired ring, or null. + static Future?> pairedRingRow() async { + for (final r in await LocalDb.deviceRows()) { + if (r['adapter_id'] == kRingConn.id) return r; + } + return null; + } + + /// Forget a paired ring: drop its `device` row. Unlike Oura there is no + /// stored secret to drop alongside it — nothing this handshake needs is + /// persisted host-side at all (see this file's own header). + static Future forgetRing(String id) async { + if (id == LocalDb.kPrimaryDeviceId) { + debugPrint('[ringconn] refusing to forget the primary band from here.'); + return false; + } + if (instance._deviceId == id) { + await instance.stop(); + } + await LocalDb.deleteDevice(id); + return true; + } + + BluetoothDevice? _device; + + /// Kept only so teardown can [GattBandLink.close] it — that is what stops a + /// write the adapter queued before teardown from landing on a LATER + /// connection to the same ring. + GattBandLink? _link; + + /// The session driving [RingConnAdapter] over [_link] — see + /// `adapters/host.dart`. + BandHost? _host; + + /// `device.id` of the paired ring — the `device_id` every row it writes + /// carries. Never [LocalDb.kPrimaryDeviceId]: `''` is the primary band, + /// permanently (ASSUMPTIONS A1). + String? _deviceId; + + /// Wall-clock now, in Unix seconds. A field so a replay is deterministic. + int Function() _now = () => DateTime.now().millisecondsSinceEpoch ~/ 1000; + + bool _busy = false; + + /// Connect to the paired ring, drain both history channels, disconnect. + /// + /// Returns false when nothing is paired or the connect failed. SERIALISED: + /// a second call while one is in flight is a no-op rather than a second + /// radio session over the same peripheral. + Future sync() { + if (_busy) return Future.value(false); + _busy = true; + return _sync().whenComplete(() => _busy = false); + } + + Future _sync() async { + final row = await pairedRingRow(); + if (row == null) return false; + final deviceId = row['id'] as String?; + final remoteId = row['remote_id'] as String?; + if (deviceId == null || remoteId == null || remoteId.isEmpty) return false; + if (deviceId == LocalDb.kPrimaryDeviceId) { + // The primary band's id, permanently. A ring writing under it would + // interleave its seconds with the band's in one REPLACE-keyed table. + debugPrint('[ringconn] refusing to sync: the ring row claims the ' + 'primary device id — re-pair it with a minted id.'); + return false; + } + _deviceId = deviceId; + + try { + // A cap on concurrent SECONDARY links (never the band's own connect — + // see ble_state.dart's kMaxConcurrentSecondaryLinks doc). + // + // THE TEARDOWN IS INSIDE THE CLOSURE, deliberately — see + // `oura_link.dart`'s identical comment: held in an outer `finally` it + // ran AFTER the slot had already been released, letting the next + // queued link connect while this one was still disconnecting. + return await withSecondaryLinkSlot(() async { + try { + final device = BluetoothDevice.fromId(remoteId); + _device = device; + await device.connect(timeout: const Duration(seconds: 20)); + final services = await device.discoverServices(); + final link = GattBandLink( + entry: kRingConn, + services: services, + onLog: (m) => debugPrint('[ringconn] $m'), + ); + _link = link; + final missing = + link.missingCharacteristics(kRingConn.requiredCharacteristics); + if (missing.isNotEmpty) { + debugPrint('[ringconn] ${kRingConn.label}: missing required ' + 'characteristic(s) ' + '${missing.map((u) => u.substring(0, 8)).join(", ")}.'); + return false; + } + final host = _makeHost(deviceId, RingConnAdapter(nowSeconds: _now)); + _host = host; + await host.run(link); + return true; + } finally { + // Drop the link and DISCONNECT before the slot is released. + await stop(); + } + }); + } catch (e) { + debugPrint('[ringconn] sync failed: $e'); + return false; + } + } + + /// Drop the link, flush what the session can still stamp, disconnect. + /// Safe to call when nothing is connected. + Future stop() async { + _link?.close(); + _link = null; + await _host?.stop(); + _host = null; + _deviceId = null; + final d = _device; + _device = null; + if (d != null) { + try { + await d.disconnect(); + } catch (_) {/* already gone */} + } + } + + /// Build this session's [BandHost]. One place, so `_sync()` and + /// [ingestForTest] cannot drift on what each callback does. + BandHost _makeHost(String deviceId, RingConnAdapter adapter) => BandHost( + adapter: adapter, + deviceId: deviceId, + onLog: (m) => debugPrint('[ringconn] $m'), + buildArchive: _buildArchiveRow, + nowSeconds: _now, + ); + + /// Bank one frame verbatim, decoded or not (owner rulings R1-R3): every + /// field inside a bulk-page record is undecoded, and the bytes are banked + /// now so a decoder written when someone owns a ring can be run over them. + ArchiveRecord? _buildArchiveRow(List bytes, int capturedAtMs) { + final f = parseRingConnFrame(bytes); + if (f == null) return null; + return ArchiveRecord( + hex: _hex(bytes), + // NULL, not 0. This band has no flash-record counter, and `counter` is + // what `thinRawArchiveBefore` samples on — a 0 for every row would make + // every RingConn frame `0 % 60 == 0`, i.e. permanently exempt, which is + // accidental policy (verbatim the same reasoning as `oura_link.dart`). + counter: null, + // The reply tag. `packet_type` is documented as a WHOOP inner[0], and + // this is the same thing one layer over. + packetType: f.respid, + // NULL, and it stays NULL. This ring tracks its own resume position; + // this host stamps no timestamp of its own onto any frame. + recTs: null, + capturedAt: capturedAtMs, + // ONE REASON PER TAG, so a decoder written later finds its records by + // name instead of re-scanning the table. NOT in + // `LocalDb.redrivableArchiveReasons`, deliberately and permanently — see + // `oura_link.dart`'s identical note: replaying this hex through the + // WHOOP R24 chain would run the wrong decoder over the right bytes. + reason: 'ringconn_resp_0x${f.respid.toRadixString(16).padLeft(2, '0')}', + ); + } + + /// Replay a scripted ring through the REAL [RingConnAdapter] and the real + /// write path. The only way in: the entry point is a BLE notification and + /// `flutter_blue_plus` has no simulator path. + /// + /// [reply] answers each write the way the ring would, exactly as + /// `ringconn_adapter_test.dart` scripts it. + @visibleForTesting + Future ingestForTest( + String deviceId, + List systemId, + List> Function(int writeIndex, List value) reply, { + int Function()? nowSeconds, + Duration timeouts = const Duration(milliseconds: 50), + }) async { + _now = nowSeconds ?? _now; + _deviceId = deviceId; + final link = ReplayBandLink()..readValues[kSystemIdUuid] = systemId; + final host = _makeHost( + deviceId, + RingConnAdapter(nowSeconds: _now, replyTimeout: timeouts), + ); + _host = host; + // `host.run` does not resolve until the session ends, but this loop has + // to react to each write WHILE the session is still open — so track + // completion alongside it rather than awaiting it here. + var finished = false; + final done = host.run(link).whenComplete(() => finished = true); + var served = 0; + for (var spin = 0; spin < 800 && !finished; spin++) { + await Future.delayed(Duration.zero); + while (served < link.writes.length) { + for (final f in reply(served, link.writes[served].$2)) { + link.feed(kRingConnNotifyChar, f, atSec: _now()); + } + served++; + } + } + await link.close(); + await done.timeout(const Duration(seconds: 2), onTimeout: () {}); + await host.stop(); + _host = null; + _deviceId = null; + return link; + } +} diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index bdd367f56..235528223 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1726,8 +1726,19 @@ const int kAlgoVersion = 87; // main took analytics 7105256 → 187e026 (the v80 gate above); this branch // took protocol 19d7291 → 6664854. kAlgoVersion is main's 81 — this branch // moves no derivation maths, which is why its own note says NO bump. +// +// REPIN (this branch) @ b819ee0 — protocol `feat/ringconn`, 2 commits on top +// of 471034c. Adds the ring's frame codec (`parseRingConnFrame` and +// friends); no decoded field, so no kAlgoVersion move. Must match +// pubspec.yaml's `ref:` — see this file's own note above. +// +// MERGE (main → this branch): main's repin (lefun @ 4284f9a) and this +// branch's (ringconn @ b819ee0) are parallel protocol commits, neither an +// ancestor of the other. protocol's own `origin/main` already merged both — +// this pin moves to that tip (fe1464d) to match pubspec.yaml's `ref:` +// rather than picking one single-device SHA over the other. const String kAnalyticsPin = '1fa8144a5e3b728ce91eeed6ecbc15d482933b44'; -const String kProtocolPin = '4284f9a2829381f4a09cdda27fa72a4bdacfa231'; +const String kProtocolPin = 'fe1464db98b84ac4d3ce6175d54ada11356d6c62'; // Fold idempotency, the minimum-nights warm-up, and legacy-payload handling // all live in SleepProfilePolicy (pure, unit-tested) — see diff --git a/lib/sync/background_sync.dart b/lib/sync/background_sync.dart index 39c3078de..340843f57 100644 --- a/lib/sync/background_sync.dart +++ b/lib/sync/background_sync.dart @@ -28,6 +28,7 @@ import '../ble/lefun_link.dart'; import '../ble/oura_link.dart'; import '../ble/pinetime_link.dart'; import '../ble/qhybrid_link.dart'; +import '../ble/ringconn_link.dart'; import '../compute/derivation_engine.dart'; import '../compute/profile.dart'; import '../data/db.dart'; @@ -219,17 +220,27 @@ Future runHeadlessSync({BandLease? lease}) async { '(${BandOwnership.debugState})', ); BandOwnership.release(ownedLease); - // Piggyback the ring on the same OS wake window, after the band work is - // fully done so it can never delay or interfere with WHOOP's own timing. - // OuraLink.sync() already no-ops when nothing is paired and never throws, - // but this is the one shared headless entry point (every wake source - // funnels through runHeadlessSync via HeadlessSyncGate) so a failure here - // must not be allowed to escape and mark the WHOOP cycle as errored. + // Piggyback paired sensors on the same OS wake window, after the band + // work is fully done so neither can ever delay or interfere with WHOOP's + // own timing. Both `sync()` calls already no-op when nothing is paired + // and never throw, but this is the one shared headless entry point (every + // wake source funnels through runHeadlessSync via HeadlessSyncGate) so a + // failure in either must not be allowed to escape and mark the WHOOP + // cycle as errored. try { await OuraLink.instance.sync(); } catch (e) { debugPrint('[bgsync] oura sync skipped: $e'); } + // Same piggyback, same reasoning: RingConnLink.sync() no-ops when nothing + // is paired and never throws, but this is still the one shared headless + // entry point, so a failure here must not escape and mark the WHOOP + // cycle as errored either. + try { + await RingConnLink.instance.sync(); + } catch (e) { + debugPrint('[bgsync] ringconn sync skipped: $e'); + } // Same piggyback, same reasoning: LefunLink.sync() no-ops when nothing is // paired and never throws, but this is still the one shared headless // entry point, so a failure here must not escape and mark the WHOOP diff --git a/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart index c1429f48a..7ce4b5b8d 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -52,6 +52,7 @@ import '../../ble/adapters/_registry.dart' kHPlus, kLefun, kOura, + kRingConn, kCasio, kDt78, kPineTime, @@ -67,6 +68,7 @@ import '../../ble/oura_link.dart' show OuraLink, pairOuraRing; import '../../ble/hplus_link.dart' show HPlusLink; import '../../ble/pinetime_link.dart' show PineTimeLink; import '../../ble/qhybrid_link.dart' show QHybridLink, pairQHybrid; +import '../../ble/ringconn_link.dart' show RingConnLink; import '../../ble/band_status_l10n.dart' show localizedBandStatus; import '../../ble/ble_state.dart' show BandStatus, kMaxConcurrentSecondaryLinks; import '../../data/db.dart' show LocalDb; @@ -792,7 +794,7 @@ class HealthSource { /// The glyph for a paired sensor. A ring is not a chest strap and the row is /// the only place a user can tell two paired sensors apart at a glance. IconData sensorIcon(String? adapterId) => switch (adapterId) { - 'oura' || 'lefun' || 'colmi' => LucideIcons.circleDot, + 'oura' || 'lefun' || 'colmi' || 'ringconn' => LucideIcons.circleDot, 'dt78' || 'hplus' || 'pinetime' || 'qhybrid' || 'casio' || 'jyou' => LucideIcons.watch, _ => LucideIcons.heartPulse, @@ -973,6 +975,17 @@ final List<({BandEntry entry, String blurb, Future Function(BluetoothDe 'unpair the ring), then close that app before pairing here.', pick: pairOuraRing, ), + ( + entry: kRingConn, + blurb: 'Pairs directly, no app or account needed. Every sync starts from ' + 'now rather than a saved bookmark, so a sync run right after the ' + 'RingConn app’s own sync can come back looking emptier than expected ' + '— the ring shares one resume point between whichever app reads it ' + 'first.', + // Null means the plain notify-class pairing — the whole handshake lives + // inside RingConnAdapter.run, same as kBleHrs. + pick: null, + ), ( entry: kDt78, blurb: 'Pairs and banks its raw data in the background, but does not ' @@ -1578,7 +1591,7 @@ class _DeviceDetailState extends State { // which a sensor has — pointing this at it would have unpaired the // WHOOP from a chest strap's page. onSync: switch (s.family) { - 'oura' => () => _syncRing(c), + 'oura' || 'ringconn' => () => _syncRing(c, s.family), 'dt78' => () => _syncDt78(c), 'hplus' => () => _syncHPlus(c), 'pinetime' => () => _syncPineTime(c), @@ -1600,12 +1613,14 @@ class _DeviceDetailState extends State { /// Drain the ring, now, because the user asked. The result is a sentence /// either way: a sync that silently did nothing is indistinguishable from a /// ring that had nothing to give, and those need different remedies. -Future _syncRing(BuildContext c) async { +Future _syncRing(BuildContext c, String? family) async { final l = AppLocalizations.of(c); final messenger = ScaffoldMessenger.maybeOf(c); messenger?.showSnackBar( SnackBar(content: Text(l?.devicesSyncingTheRing ?? 'Syncing the ring…'))); - final ok = await OuraLink.instance.sync(); + final ok = family == 'ringconn' + ? await RingConnLink.instance.sync() + : await OuraLink.instance.sync(); if (!c.mounted) return; messenger?.showSnackBar(SnackBar( content: Text(ok diff --git a/pubspec.lock b/pubspec.lock index 12d2944af..98c70f9ec 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -946,8 +946,8 @@ packages: dependency: "direct main" description: path: "." - ref: "4284f9a2829381f4a09cdda27fa72a4bdacfa231" - resolved-ref: "4284f9a2829381f4a09cdda27fa72a4bdacfa231" + ref: fe1464db98b84ac4d3ce6175d54ada11356d6c62 + resolved-ref: fe1464db98b84ac4d3ce6175d54ada11356d6c62 url: "https://github.com/OpenStrap/protocol.git" source: git version: "1.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 590f56679..00f5029bd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -135,8 +135,29 @@ dependencies: # nothing else. No decoded physiological field (owner ruling: nobody on # this project owns the hardware). # + # NO kAlgoVersion bump: every changed decoder here is on the live + # realtime path (decodeFrame's `realtime_hr`/`realtime_small`, R11) — + # live high-rate streams are never persisted (see invariant #1), so no + # stored day_result number moves. R11's meaning is still unconfirmed + # and edge does not read it. + # + # REPIN (this branch): protocol `feat/ringconn` @ b819ee0, 2 commits on + # top of 471034c. Adds the ring's frame codec this branch's + # `lib/ble/adapters/ringconn.dart` calls: `parseRingConnFrame`, + # `RingConnFrame`, the bulk-page reader, and the channel constants + # (`kRingConnChannelSleep`/`...Awake`). No decoded field — raw frames + # only (owner ruling: nobody on this project owns the hardware). + # # NO kAlgoVersion bump: nothing here feeds a stored metric. - ref: 4284f9a2829381f4a09cdda27fa72a4bdacfa231 + # + # MERGE (main → this branch): main's own repin (lefun @ 4284f9a) and + # this branch's (ringconn @ b819ee0) are parallel commits on protocol, + # neither an ancestor of the other — verified via + # `git merge-base --is-ancestor`. protocol's own `origin/main` has + # already merged both (its own independent per-device PR queue), so the + # ref moves to that tip rather than either single-device SHA — this is + # both repins standing, not a choice between them. + ref: fe1464db98b84ac4d3ce6175d54ada11356d6c62 openstrap_analytics: git: url: https://github.com/OpenStrap/analytics.git diff --git a/test/adapter_signals_registry_test.dart b/test/adapter_signals_registry_test.dart index e079a9746..cd7e7681b 100644 --- a/test/adapter_signals_registry_test.dart +++ b/test/adapter_signals_registry_test.dart @@ -21,6 +21,7 @@ import 'package:openstrap_edge/ble/adapters/lefun.dart'; import 'package:openstrap_edge/ble/adapters/oura.dart'; import 'package:openstrap_edge/ble/adapters/pinetime.dart'; import 'package:openstrap_edge/ble/adapters/qhybrid.dart'; +import 'package:openstrap_edge/ble/adapters/ringconn.dart'; import 'package:openstrap_edge/ble/adapters/signals.dart'; import 'package:openstrap_edge/ble/adapters/whoop_gen4.dart'; @@ -33,6 +34,7 @@ void main() { 'gen5': kWhoopGen4Signals, 'ble_hrs': const BleHrsAdapter().signals, 'oura': OuraAdapter(key: const [0]).signals, + 'ringconn': RingConnAdapter().signals, 'dt78': const Dt78Adapter().signals, 'lefun': LefunAdapter().signals, 'hplus': const HPlusAdapter().signals, diff --git a/test/adapters/ringconn_adapter_test.dart b/test/adapters/ringconn_adapter_test.dart new file mode 100644 index 000000000..674418482 --- /dev/null +++ b/test/adapters/ringconn_adapter_test.dart @@ -0,0 +1,305 @@ +// The RingConn session, replayed through [ReplayBandLink]. +// +// WHAT THIS EXISTS TO PROVE, and it is not the wire format itself — that is +// proven in the protocol package. It is the SHAPE of the session: +// +// * the System ID is read before anything is written, and an unreadable one +// ends the session with no writes at all; +// * the challenge is answered with SM3 over the ring's own recovered MAC, +// and a refused or unconfirmed handshake ends the session before any +// channel is opened; +// * both channels are opened at "now" and drained independently; +// * a bulk page's remaining-count byte is what pulls the next page of the +// SAME burst, never a fresh fetch; +// * every frame — auth, sync-open ack, every page — reaches `raw`, and +// `samples` is always empty: nothing here decodes a value. +// +// Nothing here has met hardware. It proves the state machine, not the ring. + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/adapters/adapter.dart'; +import 'package:openstrap_edge/ble/adapters/ringconn.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +/// Short enough that a deliberately-unanswered wait does not stall CI. +const Duration _kFast = Duration(milliseconds: 50); + +/// Forward EUI-64 form of MAC `a1:b2:c3:44:55:66` (OUI + FF FE + NIC). +final List _systemId = _hex('a1b2c3fffe445566'); +final List _mac = _hex('a1b2c3445566'); + +List _hex(String s) => [ + for (var i = 0; i + 1 < s.length; i += 2) + int.parse(s.substring(i, i + 2), radix: 16), + ]; + +RingConnAdapter _adapter({int Function()? nowSeconds}) => RingConnAdapter( + nowSeconds: nowSeconds ?? (() => 1786000000), + replyTimeout: _kFast, + ); + +List _xorFrame(int respid, List body) { + final full = [respid, ...body]; + var x = 0; + for (final b in full) { + x ^= b; + } + return [...full, x & 0xff]; +} + +List _challengeReply(int challenge) => + _xorFrame(kRingConnRespAuth, [0x00, challenge]); +List _authConfirm() => + _xorFrame(kRingConnRespAuth, [0x01, ...List.filled(35, 0)]); +List _syncOpenReply() => _xorFrame(kRingConnRespSyncOpen, [0x00, 0x00]); +List _fetchEmpty() => _xorFrame(kRingConnRespFetchEmpty, const []); + +List _bulkPage(int respid, int recordLen, int remaining, List tags) { + final records = [ + for (final t in tags) ...[t, ...List.filled(recordLen - 1, 0xaa)], + ]; + return _xorFrame(respid, [0x00, remaining, ...records]); +} + +/// Drive [adapter] over a replay link, answering each write as the ring +/// would. A replay link records writes but cannot react to them, so this +/// waits for the write count to grow and feeds the reply that write earned — +/// the smallest thing that exercises the real `run()`. +Future<(List, ReplayBandLink)> _drive( + RingConnAdapter adapter, + List> Function(int writeIndex, List value) reply, { + /// Null (the default) seeds the real fixture MAC. An explicit empty list is + /// how a test asks for an UNREADABLE System ID — `ReplayBandLink.read` + /// answers whatever is seeded here verbatim, and `RingConnAdapter` refuses + /// anything that is not exactly 8 bytes. + List? systemId, +}) async { + final link = ReplayBandLink(); + final sid = systemId ?? _systemId; + link.readValues[kSystemIdUuid] = sid; + final events = []; + final done = Completer(); + final sub = adapter.run(link).listen( + (e) { + events.add(e); + // Stand in for `BandHost`'s commit-then-confirm: nothing here + // decodes or persists, so every checkpoint confirms the instant it + // arrives — this drives the ack write same as a real commit would. + if (e is OffloadCheckpoint) unawaited(e.confirm()); + }, + onDone: () => done.complete(), + ); + var served = 0; + for (var spin = 0; spin < 400 && !done.isCompleted; spin++) { + await Future.delayed(Duration.zero); + while (served < link.writes.length) { + final w = link.writes[served]; + for (final f in reply(served, w.$2)) { + link.feed(kRingConnNotifyChar, f, atSec: 1786000000); + } + served++; + } + } + await link.close(); + await done.future.timeout(const Duration(seconds: 2), onTimeout: () {}); + await sub.cancel(); + return (events, link); +} + +void main() { + test('declares no signal at all', () { + expect(_adapter().signals, isEmpty); + }); + + test('an unreadable System ID ends the session with no writes and no ' + 'events', () async { + final (events, link) = await _drive( + _adapter(), + (i, v) => const [], + systemId: const [], + ); + expect(link.writes, isEmpty); + expect(events, isEmpty); + expect( + link.logs.any((l) => l.contains('could not read the System ID')), + isTrue, + ); + }); + + test('answers the challenge with SM3 over the recovered MAC, and both ' + 'frames reach raw', () async { + const challenge = 0x2b; + final (events, link) = await _drive(_adapter(), (i, v) { + if (v.first == kRingConnCmdStatus && v[1] == 0x00) { + return [_challengeReply(challenge)]; + } + if (v.first == kRingConnCmdStatus && v[1] == 0x01) return [_authConfirm()]; + return const []; + }); + expect(link.writes.first.$2, ringConnCmdStatus()); + final authWrite = link.writes[1].$2; + expect(authWrite.first, kRingConnCmdStatus); + expect(authWrite[1], 0x01); + expect(authWrite.sublist(2, 5), ringConnAuthResponse(_mac, challenge)); + // Every frame the ring sent — the challenge and the confirm — reaches + // `raw`, even though neither is a bulk page. + final auth = events.whereType().first; + expect(auth.raw, hasLength(2)); + expect(auth.samples, isEmpty); + }); + + test('no challenge ends the session before any channel opens', () async { + final (events, link) = + await _drive(_adapter(), (i, v) => const []); + expect(link.writes, hasLength(1), reason: 'only the status write'); + expect(events, isEmpty); + expect( + link.logs.any((l) => l.contains('no authentication challenge')), + isTrue, + ); + }); + + test('an unconfirmed handshake ends the session before any channel opens', + () async { + final (_, link) = await _drive(_adapter(), (i, v) { + if (v.first == kRingConnCmdStatus && v[1] == 0x00) { + return [_challengeReply(0x01)]; + } + return const []; + }); + expect( + link.writes.any((w) => w.$2.first == kRingConnCmdSyncOpen), + isFalse, + ); + }); + + test('opens both channels at "now" once authenticated', () async { + const now = 1786000000; + final (_, link) = await _drive( + _adapter(nowSeconds: () => now), + (i, v) { + if (v.first == kRingConnCmdStatus && v[1] == 0x00) { + return [_challengeReply(0x01)]; + } + if (v.first == kRingConnCmdStatus && v[1] == 0x01) return [_authConfirm()]; + if (v.first == kRingConnCmdSyncOpen) return [_syncOpenReply()]; + if (v.first == kRingConnCmdFetch) return [_fetchEmpty()]; + return const []; + }, + ); + final opens = + link.writes.where((w) => w.$2.first == kRingConnCmdSyncOpen).toList(); + expect(opens, hasLength(2)); + final cursor = ringConnCursor(now); + final cursorBytes = [ + (cursor >>> 24) & 0xff, + (cursor >>> 16) & 0xff, + (cursor >>> 8) & 0xff, + cursor & 0xff, + ]; + expect(opens[0].$2.sublist(2, 6), cursorBytes); + expect(opens[1].$2.sublist(2, 6), cursorBytes); + // Sleep, then awake — the one byte that differs. + expect(opens[0].$2[6], kRingConnChannelSleep); + expect(opens[1].$2[6], kRingConnChannelAwake); + }); + + test('a bulk page with remaining > 0 is ACKed to pull the next page of the ' + 'SAME burst, never a fresh fetch', () async { + var acks = 0; + final (events, link) = await _drive(_adapter(), (i, v) { + if (v.first == kRingConnCmdStatus && v[1] == 0x00) { + return [_challengeReply(0x01)]; + } + if (v.first == kRingConnCmdStatus && v[1] == 0x01) return [_authConfirm()]; + if (v.first == kRingConnCmdSyncOpen) return [_syncOpenReply()]; + if (v.first == kRingConnCmdFetch) { + return [_bulkPage(kRingConnRespBulkActivity, kRingConnActivityRecordLen, 1, [0x01])]; + } + if (v.first == kRingConnCmdAckActivity) { + acks++; + return [_bulkPage(kRingConnRespBulkActivity, kRingConnActivityRecordLen, 0, [0x02])]; + } + return const []; + }); + // One channel drains two pages before ending; the other channel (whose + // fetch is answered the same way by this script) drains two more. + expect(acks, 2); + final batches = events.whereType().toList(); + // auth (1) + each channel yields its own frame the moment it arrives + // (sync-open ack, page 1, page 2 — one batch apiece, never accumulated + // for a single yield at the end of the burst) — 1 + 2*3. + expect(batches, hasLength(7)); + for (final b in batches.skip(1)) { + expect(b.samples, isEmpty); + expect(b.raw, hasLength(1)); + } + final page2 = _bulkPage( + kRingConnRespBulkActivity, + kRingConnActivityRecordLen, + 0, + [0x02], + ); + expect( + batches.last.raw!.single, + Uint8List.fromList(page2), + reason: 'the last page reaches raw verbatim, header included', + ); + // Never any command this file did not build. + const built = { + kRingConnCmdStatus, + kRingConnCmdSyncOpen, + kRingConnCmdFetch, + kRingConnCmdAckActivity, + }; + for (final w in link.writes) { + expect(built, contains(w.$2.first)); + } + }); + + test('a non-bulk reply ends the burst even when nothing was queued to ' + 'follow it', () async { + final (events, _) = await _drive(_adapter(), (i, v) { + if (v.first == kRingConnCmdStatus && v[1] == 0x00) { + return [_challengeReply(0x01)]; + } + if (v.first == kRingConnCmdStatus && v[1] == 0x01) return [_authConfirm()]; + if (v.first == kRingConnCmdSyncOpen) return [_syncOpenReply()]; + if (v.first == kRingConnCmdFetch) return [_fetchEmpty()]; + return const []; + }); + final batches = events.whereType().toList(); + // auth (1) + each channel's own sync-open-ack and fetch-empty frames, + // one batch apiece — 1 + 2*2. + expect(batches, hasLength(5)); + for (final b in batches) { + expect(b.samples, isEmpty); + } + }); + + test('a ring that goes silent mid-burst ends the session instead of ' + 'hanging, and still banks what it already sent', () async { + final (events, _) = await _drive(_adapter(), (i, v) { + if (v.first == kRingConnCmdStatus && v[1] == 0x00) { + return [_challengeReply(0x01)]; + } + if (v.first == kRingConnCmdStatus && v[1] == 0x01) return [_authConfirm()]; + if (v.first == kRingConnCmdSyncOpen) return [_syncOpenReply()]; + if (v.first == kRingConnCmdFetch) { + // remaining > 0, but the ring never answers the ACK this earns. + return [_bulkPage(kRingConnRespBulkActivity, kRingConnActivityRecordLen, 1, [0x01])]; + } + return const []; + }); + final batches = events.whereType().toList(); + // auth + sleep-channel (banked what arrived before the timeout). + // The awake channel's own sync-open never gets a reply either, once the + // fetch that starts it times out the same way — either way nothing hangs. + expect(batches, isNotEmpty); + expect(batches.every((b) => b.samples.isEmpty), isTrue); + }); +} diff --git a/test/band_registry_test.dart b/test/band_registry_test.dart index b6587177c..13cfe5fd4 100644 --- a/test/band_registry_test.dart +++ b/test/band_registry_test.dart @@ -16,6 +16,7 @@ void main() { 'gen5', 'ble_hrs', 'oura', + 'ringconn', 'dt78', 'lefun', 'hplus', diff --git a/test/ringconn_link_test.dart b/test/ringconn_link_test.dart new file mode 100644 index 000000000..b664bbe56 --- /dev/null +++ b/test/ringconn_link_test.dart @@ -0,0 +1,237 @@ +// The RingConn HOST: scripted frames in, `raw_archive` out, nothing else. +// +// NOTHING HERE HAS MET HARDWARE. Nobody on this project owns a ring (owner +// ruling R6) and `flutter_blue_plus` has no simulator path, so the ring below +// is a script and the frames are hand-built to the shapes the protocol +// package's RingConn wire format documents. It pins the HOST — banking every +// byte, never putting a command on the wire that no builder produced, and +// never writing a `decoded_onehz` row — and it proves nothing about a real +// ring. +// +// `ringconn_adapter_test.dart` already proves the session state machine. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/ringconn_link.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +const String _deviceId = 'ringconn-0a1b2c3d'; + +/// Forward EUI-64 form of MAC `a1:b2:c3:44:55:66` (OUI + FF FE + NIC). +final List _systemId = _hex('a1b2c3fffe445566'); +final List _mac = _hex('a1b2c3445566'); + +const int _nowSec = 1786000000; + +List _hex(String s) => [ + for (var i = 0; i + 1 < s.length; i += 2) + int.parse(s.substring(i, i + 2), radix: 16), + ]; + +List _xorFrame(int respid, List body) { + final full = [respid, ...body]; + var x = 0; + for (final b in full) { + x ^= b; + } + return [...full, x & 0xff]; +} + +List _challengeReply(int challenge) => + _xorFrame(kRingConnRespAuth, [0x00, challenge]); +List _authConfirm() => + _xorFrame(kRingConnRespAuth, [0x01, ...List.filled(35, 0)]); +/// Echoes [channel] back in the reply so this fixture — and the two +/// channels' archive rows below — can tell the sleep and awake drains apart. +List _syncOpenReply(int channel) => + _xorFrame(kRingConnRespSyncOpen, [0x00, channel]); + +/// [tag] (the channel byte) leads every record, so the sleep and awake +/// drains' pages hash to different `raw_archive` rows instead of colliding on +/// `(device_id, hex)` the way two byte-identical frames would. +List _bulkPage(int respid, int recordLen, int remaining, int count, + {required int tag}) { + final records = [ + for (var i = 0; i < count; i++) ...[ + tag, + ...List.filled(recordLen - 1, 0x11 * (i + 1)), + ], + ]; + return _xorFrame(respid, [0x00, remaining, ...records]); +} + +/// A ring that authenticates, opens both channels, and hands each one +/// [recordsPerChannel] activity records in a single page before ending. +List> Function(int, List) _ring({int recordsPerChannel = 1}) { + var lastChannel = -1; + return (int i, List v) { + if (v.first == kRingConnCmdStatus && v[1] == 0x00) return [_challengeReply(0x2b)]; + if (v.first == kRingConnCmdStatus && v[1] == 0x01) return [_authConfirm()]; + if (v.first == kRingConnCmdSyncOpen) { + lastChannel = v[6]; + return [_syncOpenReply(lastChannel)]; + } + if (v.first == kRingConnCmdFetch) { + return [ + _bulkPage( + kRingConnRespBulkActivity, + kRingConnActivityRecordLen, + 0, + recordsPerChannel, + tag: lastChannel, + ), + ]; + } + return const >[]; + }; +} + +Future _run({ + int recordsPerChannel = 1, + List? systemId, +}) async { + final link = await RingConnLink.instance.ingestForTest( + _deviceId, + systemId ?? _systemId, + _ring(recordsPerChannel: recordsPerChannel), + nowSeconds: () => _nowSec, + ); + final db = await LocalDb.instance; + return ReplayBandLinkResult( + writes: [for (final w in link.writes) w.$2], + onehz: await db.query('decoded_onehz', orderBy: 'ts_ms'), + archive: await db.query('raw_archive', orderBy: 'captured_at, hex'), + ); +} + +class ReplayBandLinkResult { + final List> writes; + final List> onehz; + final List> archive; + const ReplayBandLinkResult({ + required this.writes, + required this.onehz, + required this.archive, + }); +} + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + await LocalDb.close(); + LocalDb.dbName = 'ringconn_link_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDown(() async => LocalDb.close()); + + test('every frame is banked verbatim, decoded or not, and nothing is ever ' + 'decoded into a sample', () async { + final r = await _run(); + // Both channels drain: auth (2 frames) + 2 channels x (open + page) = 6. + expect(r.archive, hasLength(6)); + expect(r.onehz, isEmpty, + reason: 'RingConnAdapter.signals is const {} — nothing is decoded'); + // One reason per reply tag, so a decoder written later finds its records + // by name. + expect( + r.archive.map((a) => a['reason']).toSet(), + { + 'ringconn_resp_0x${kRingConnRespAuth.toRadixString(16)}', + 'ringconn_resp_0x${kRingConnRespSyncOpen.toRadixString(16)}', + 'ringconn_resp_0x${kRingConnRespBulkActivity.toRadixString(16)}', + }, + ); + // NOT re-drivable, and that is deliberate — see `oura_link_test.dart`'s + // identical assertion: replaying this hex through the WHOOP R24 chain + // would run the wrong decoder over the right bytes. + for (final a in r.archive) { + expect(LocalDb.redrivableArchiveReasons, isNot(contains(a['reason']))); + } + }); + + test('nothing this host writes is a frame no builder in the protocol ' + 'package produced', () async { + final r = await _run(); + expect(r.writes, isNotEmpty); + for (final w in r.writes) { + final rebuilt = switch (w.first) { + kRingConnCmdStatus when w[1] == 0x00 => ringConnCmdStatus(), + kRingConnCmdStatus => ringConnCmdAuthResponse(w.sublist(2, 5)), + kRingConnCmdSyncOpen => ringConnCmdSyncOpen( + w[2] << 24 | w[3] << 16 | w[4] << 8 | w[5], + w[6], + ), + kRingConnCmdFetch => ringConnCmdFetch(), + kRingConnCmdAckActivity => ringConnCmdAckActivity(), + _ => throw StateError( + 'unbuilt command tag 0x${w.first.toRadixString(16)}'), + }; + expect(w, rebuilt); + } + // And the auth response really is SM3 over the ring's own MAC — not a + // separate crypto surface to re-verify here, since `ringConnAuthResponse` + // is protocol's own function called directly, with nothing edge-specific + // in between. + final authWrite = + r.writes.firstWhere((w) => w.first == kRingConnCmdStatus && w[1] == 0x01); + expect(authWrite.sublist(2, 5), ringConnAuthResponse(_mac, 0x2b)); + }); + + test('the band-only readers cannot see a ring row', () async { + await _run(); + final db = await LocalDb.instance; + final banded = await db.rawQuery( + 'SELECT COUNT(*) c FROM decoded_onehz WHERE source IS NULL', + ); + expect(banded.first['c'], 0, + reason: 'every derive/export read filters `source IS NULL`'); + }); + + test('an unreadable System ID writes nothing at all', () async { + // `ReplayBandLink.read` answers null for an unseeded uuid — the same + // shape a real missing/unreadable characteristic takes. + final r = await _run(systemId: const []); + expect(r.writes, isEmpty); + expect(r.archive, isEmpty); + }); + + test('nothing paired means nothing to sync', () async { + expect(await RingConnLink.pairedRingRow(), isNull); + expect(await RingConnLink.instance.sync(), isFalse); + }); + + group('forgetRing', () { + test('drops the device row', () async { + await LocalDb.upsertDevice( + id: _deviceId, + adapterId: kRingConn.id, + remoteId: 'AA:BB:CC:DD:EE:FF', + label: 'Ring', + ); + expect(await RingConnLink.pairedRingRow(), isNotNull); + final ok = await RingConnLink.forgetRing(_deviceId); + expect(ok, isTrue); + expect(await RingConnLink.pairedRingRow(), isNull); + }); + + test('refuses the primary device id outright', () async { + final ok = await RingConnLink.forgetRing(LocalDb.kPrimaryDeviceId); + expect(ok, isFalse); + }); + + test('a device id nothing paired is a harmless no-op', () async { + final ok = await RingConnLink.forgetRing('ringconn-never-paired'); + expect(ok, isTrue); + expect(await RingConnLink.pairedRingRow(), isNull); + }); + }); +}