From 57b3890a5ca3df8b959d3ae17e96e88607ac957e Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:20:46 +0530 Subject: [PATCH 1/5] ringconn: pair + drain, raw archive only, no decode adds a BandLink.read() seam (ringconn's auth needs one GATT read that nothing else did) and a per-entry pairTier so a second zero-signal notify-class band can't inherit ble_hrs's beat-to-beat tag. --- lib/ble/adapters/_registry.dart | 89 ++++++- lib/ble/adapters/adapter.dart | 19 ++ lib/ble/adapters/gatt_link.dart | 23 ++ lib/ble/adapters/ringconn.dart | 309 +++++++++++++++++++++++ lib/ble/hrs_link.dart | 24 +- lib/ble/ringconn_link.dart | 256 +++++++++++++++++++ lib/sync/background_sync.dart | 19 +- lib/ui2/profile/devices.dart | 18 +- test/adapter_signals_registry_test.dart | 2 + test/adapters/ringconn_adapter_test.dart | 296 ++++++++++++++++++++++ test/band_registry_test.dart | 2 +- test/ringconn_link_test.dart | 237 +++++++++++++++++ 12 files changed, 1275 insertions(+), 19 deletions(-) create mode 100644 lib/ble/adapters/ringconn.dart create mode 100644 lib/ble/ringconn_link.dart create mode 100644 test/adapters/ringconn_adapter_test.dart create mode 100644 test/ringconn_link_test.dart diff --git a/lib/ble/adapters/_registry.dart b/lib/ble/adapters/_registry.dart index ae24d143e..f2579af13 100644 --- a/lib/ble/adapters/_registry.dart +++ b/lib/ble/adapters/_registry.dart @@ -53,9 +53,26 @@ 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 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'; @@ -253,6 +270,18 @@ class BandEntry { /// the ALREADY-LOWERCASED platform name. final bool Function(String lowercaseName)? nameMatcher; + /// The `device.tier` a plain notify-class pairing (`pick: null` in + /// `kPairableSensors`) writes for THIS band, unless the pairing call site + /// overrides it. Null is a refusal, not an oversight: `HealthSource.tier` + /// decides measurement-quality precedence between sources, and a band that + /// declares no [InputSignal] at all (`OuraAdapter.signals` / a future + /// `RingConnAdapter.signals`, both `const {}`) has no quality to rank — + /// see `devices.dart`'s own doc on why Oura's pairing leaves this unset. + /// `HrsLink.pairNotifySensor` falls back to this field exactly so a SECOND + /// zero-signal notify-class band does not silently inherit the first one's + /// hardcoded `'beatToBeat'` default. + final String? pairTier; + /// A framed WHOOP-family band: an envelope, a command characteristic, and a /// flash the offload engine trims. const BandEntry.framed({ @@ -274,6 +303,9 @@ class BandEntry { }) : _requiredCharacteristics = requiredCharacteristics, _commands = commands, _service = null, + // Meaningless for a framed band — it never reaches + // `HrsLink.pairNotifySensor`, which is the only reader of this field. + pairTier = null, timeAnchor = TimeAnchor.measured; /// A notify-only sensor: one service, one or more notify characteristics, no @@ -289,6 +321,8 @@ class BandEntry { required String service, required List characteristics, required this.timeAnchor, + this.nameMatcher, + this.pairTier, }) : _service = service, _requiredCharacteristics = characteristics, gatt = null, @@ -301,7 +335,6 @@ class BandEntry { setClockDriftGated = false, burstCountGateEnforced = false, logsConsoleOutput = false, - nameMatcher = null, innerOpcodeOffset = -1, innerVersionOffset = -1, innerCounterOffset = -1; @@ -410,6 +443,10 @@ const BandEntry kBleHrs = BandEntry.notify( characteristics: [kHeartRateMeasurementUuid], // The strap reports durations and has no clock. See [TimeAnchor]. timeAnchor: TimeAnchor.arrival, + // Genuinely beat-to-beat: electrical R-peak detection is exactly what + // [SourceTier.beatToBeat] means. Written out explicitly now that a SECOND + // `pick: null` band exists ([kRingConn]) which must NOT inherit it. + pairTier: 'beatToBeat', ); /// The Oura ring, a fetch-by-cursor band with a challenge-response handshake. @@ -438,12 +475,52 @@ 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`. +/// +/// `pairTier` is left null (its default), same reasoning as Oura's own +/// pairing: a band with no declared signal has no measurement quality to +/// rank. It is `pick: null` in `kPairableSensors` even so — see +/// `BandEntry.pairTier`'s own doc for why that no longer risks inheriting +/// [kBleHrs]'s `'beatToBeat'`. +const BandEntry kRingConn = BandEntry.notify( + id: 'ringconn', + label: 'RingConn', + service: kRingConnService, + characteristics: [ + kRingConnCommandChar, + kRingConnNotifyChar, + kSystemIdUuid, + ], + timeAnchor: TimeAnchor.arrival, +); + /// Every band this build can see. Order is match order during discovery. const List kBandRegistry = [ kWhoopGen4, kWhoopGen5, kBleHrs, kOura, + kRingConn, ]; /// The bands the OFFLOAD ENGINE can drive, and the bands iOS provisions @@ -471,10 +548,11 @@ BandEntry bandEntryFor(BandProfile wire) => /// `signals` getter is a plain literal with no computed dependency, so this /// 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` and `oura.dart`'s -/// `OuraAdapter.signals`, since importing those three back into this file -/// (each of which already imports THIS file for its `BandEntry`) would be a -/// needless import cycle for three lines of data. +/// `ble_hrs.dart`'s `BleHrsAdapter.signals`, `oura.dart`'s +/// `OuraAdapter.signals` and `ringconn.dart`'s `RingConnAdapter.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 @@ -500,6 +578,7 @@ const Map> kAdapterSignals = InputSignal.rrIntervals: Duration(seconds: 1), }, 'oura': {}, + 'ringconn': {}, }; /// The signals one adapter declares, or empty for an id this build has no 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..84806aa2d --- /dev/null +++ b/lib/ble/adapters/ringconn.dart @@ -0,0 +1,309 @@ +// 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); + 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 raw = []; + final cursor = ringConnCursor(nowSeconds()); + if (!await link.write( + kRingConnCommandChar, + ringConnCmdSyncOpen(cursor, channel), + )) { + link.log('ringconn: sync-open refused on channel $channel; skipping ' + 'it.'); + if (raw.isNotEmpty) yield SampleBatch(const [], raw: raw); + return; + } + final opened = await inbox.firstWhere( + (f) => f.respid == kRingConnRespSyncOpen, + replyTimeout, + onEach: raw.add, + ); + if (opened == null) { + link.log('ringconn: no sync-open reply on channel $channel.'); + if (raw.isNotEmpty) yield SampleBatch(const [], raw: raw); + return; + } + if (!await link.write(kRingConnCommandChar, ringConnCmdFetch())) { + link.log('ringconn: fetch refused on channel $channel; ending its ' + 'drain.'); + if (raw.isNotEmpty) yield SampleBatch(const [], raw: raw); + return; + } + + for (var page = 0; 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; + raw.add(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(); + if (!await link.write(kRingConnCommandChar, ack)) { + 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. + } + if (raw.isNotEmpty) yield SampleBatch(const [], raw: raw); + } +} + +/// 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); + } + + /// 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 07b95f006..97171741a 100644 --- a/lib/ble/hrs_link.dart +++ b/lib/ble/hrs_link.dart @@ -66,6 +66,7 @@ import 'ble_state.dart' acquireSecondaryLinkSlot, releaseSecondaryLinkSlot; import 'oura_link.dart' show OuraLink; +import 'ringconn_link.dart' show RingConnLink; export 'adapters/host.dart' show HrsReading; @@ -497,9 +498,13 @@ class HrsLink { /// that DOES need a key exchange supplies its own step to the screen and /// never calls this. /// - /// [tier] defaults to the strap's, and a band whose measurement quality - /// differs must say so rather than inherit it — the tier is what decides - /// precedence between two sources, so a wrong one is a silent wrong number. + /// [tier] defaults to [BandEntry.pairTier] — the registry entry's OWN + /// answer to "what is this band's measurement quality", so a caller that + /// omits it can never silently inherit some OTHER band's tier the way a + /// hardcoded literal default would the moment a second `pick: null` band + /// existed (see that field's own doc). Passing it explicitly still wins, + /// for the one caller that genuinely needs to say something different from + /// the registry's default. /// /// Nothing is written unless the peripheral passed the characteristic check: /// a row pointing at a device that cannot answer is a sensor that appears @@ -508,7 +513,7 @@ class HrsLink { BandEntry entry, BluetoothDevice device, { String? label, - String tier = 'beatToBeat', + String? tier, }) async { try { await device.connect(timeout: _connectTimeout); @@ -538,7 +543,7 @@ class HrsLink { adapterId: entry.id, remoteId: device.remoteId.str, label: label, - tier: tier, + tier: tier ?? entry.pairTier, ); return null; } catch (e) { @@ -569,7 +574,10 @@ 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. + /// 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. static Future forgetDevice(String id) async { if (id == LocalDb.kPrimaryDeviceId) { debugPrint('[hrs] refusing to forget the primary band from here.'); @@ -582,6 +590,10 @@ class HrsLink { await OuraLink.forgetRing(id); return; } + if (row?['adapter_id'] == kRingConn.id) { + await RingConnLink.forgetRing(id); + return; + } // Before the row goes, not after: a live session would keep writing rows // under an id nothing can explain any more. await instance.disarm(); 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/sync/background_sync.dart b/lib/sync/background_sync.dart index 26db82d85..297bfafd5 100644 --- a/lib/sync/background_sync.dart +++ b/lib/sync/background_sync.dart @@ -22,6 +22,7 @@ import '../ble/adapters/host.dart' show BandHost; import '../ble/adapters/whoop_gen4.dart' show WhoopFramedAdapter; import '../ble/ble_engine.dart'; import '../ble/oura_link.dart'; +import '../ble/ringconn_link.dart'; import '../compute/derivation_engine.dart'; import '../compute/profile.dart'; import '../data/db.dart'; @@ -213,17 +214,23 @@ 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'); } + try { + await RingConnLink.instance.sync(); + } catch (e) { + debugPrint('[bgsync] ringconn sync skipped: $e'); + } } } diff --git a/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart index 784866d0a..a151b87e7 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -43,7 +43,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; import '../../ble/adapters/_registry.dart' - show BandEntry, kBandRegistry, kBleHrs, kOura, declaredSignals; + show BandEntry, kBandRegistry, kBleHrs, kOura, kRingConn, declaredSignals; import '../../ble/adapters/signals.dart' show InputSignal; import '../../ble/hrs_link.dart' show HrsLink, HrsReading; import '../../ble/oura_link.dart' show OuraLink, pairOuraRing; @@ -773,6 +773,11 @@ class HealthSource { /// the only place a user can tell two paired sensors apart at a glance. IconData sensorIcon(String? adapterId) => switch (adapterId) { 'oura' => LucideIcons.circleDot, + // A generic icon-library glyph, never a brand mark (see + // `device_picker.dart`'s own note on that rule) — and distinct from + // Oura's on purpose, so two rings on one screen do not read as the + // same device. + 'ringconn' => LucideIcons.gem, _ => LucideIcons.heartPulse, }; @@ -951,6 +956,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, + ), ]; /// Choose which kind of sensor to pair, then hand off to the pairing screen. diff --git a/test/adapter_signals_registry_test.dart b/test/adapter_signals_registry_test.dart index 94ab6e7d4..91b28c968 100644 --- a/test/adapter_signals_registry_test.dart +++ b/test/adapter_signals_registry_test.dart @@ -13,6 +13,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/ble/adapters/_registry.dart'; import 'package:openstrap_edge/ble/adapters/ble_hrs.dart'; import 'package:openstrap_edge/ble/adapters/oura.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'; @@ -25,6 +26,7 @@ void main() { 'gen5': kWhoopGen4Signals, 'ble_hrs': const BleHrsAdapter().signals, 'oura': OuraAdapter(key: const [0]).signals, + 'ringconn': RingConnAdapter().signals, }; // Every registry id is covered above: a NEW adapter must extend this test, diff --git a/test/adapters/ringconn_adapter_test.dart b/test/adapters/ringconn_adapter_test.dart new file mode 100644 index 000000000..224a9068c --- /dev/null +++ b/test/adapters/ringconn_adapter_test.dart @@ -0,0 +1,296 @@ +// 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), + 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) + sleep-channel (1) + awake-channel (1). + expect(batches, hasLength(3)); + for (final b in batches.skip(1)) { + expect(b.samples, isEmpty); + // sync-open ack + page 1 (fetch) + page 2 (ack reply) = 3 frames. + expect(b.raw, hasLength(3)); + } + final page2 = _bulkPage( + kRingConnRespBulkActivity, + kRingConnActivityRecordLen, + 0, + [0x02], + ); + expect( + batches[1].raw!.last, + 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(); + expect(batches, hasLength(3)); + 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 96dd7c9eb..fa2854373 100644 --- a/test/band_registry_test.dart +++ b/test/band_registry_test.dart @@ -10,7 +10,7 @@ import 'package:openstrap_protocol/openstrap_protocol.dart'; void main() { test('ids are unique and stable — they are stamped as device_family', () { expect(kBandRegistry.map((e) => e.id).toList(), - ['gen4', 'gen5', 'ble_hrs', 'oura']); + ['gen4', 'gen5', 'ble_hrs', 'oura', 'ringconn']); }); test('D1 — the scan service list is exactly the two WHOOP services', () { 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); + }); + }); +} From b149aa2ffd5102fc4c94663965f9af980a788c91 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:38:08 +0530 Subject: [PATCH 2/5] ringconn: log when the page-cap ends a burst drain early a burst that never signals done and just runs out the 512-page cap fell through with no log line, unlike every other early exit here. --- lib/ble/adapters/ringconn.dart | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/ble/adapters/ringconn.dart b/lib/ble/adapters/ringconn.dart index 84806aa2d..23178a5fa 100644 --- a/lib/ble/adapters/ringconn.dart +++ b/lib/ble/adapters/ringconn.dart @@ -212,7 +212,8 @@ class RingConnAdapter extends BandAdapter { return; } - for (var page = 0; page < _kMaxPagesPerBurst; page++) { + 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 ' @@ -241,6 +242,16 @@ class RingConnAdapter extends BandAdapter { // 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.'); + } if (raw.isNotEmpty) yield SampleBatch(const [], raw: raw); } } From e2747935959785c0c49f608a7842f631bcbca6c6 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:46:31 +0530 Subject: [PATCH 3/5] ringconn: manual sync in devices UI, repin protocol, drain fixes Wire the ring into the devices screen's manual sync affordance (was hardcoded to Oura only) and repin openstrap_protocol to feat/ringconn's head so the branch actually compiles in CI instead of only through a gitignored local override. Yield each drained frame to the host as it arrives instead of accumulating a whole channel's burst for one yield at the end, so a page reaches the host's buffer before the ack that advances the ring's own resume pointer, not after the whole burst finishes. Drop anything left over in the inbox between the two channels so a straggler from the first can't get mistaken for the second's sync-open reply. --- lib/ble/adapters/ringconn.dart | 37 +++++++++++++++++++----- lib/compute/derivation_engine.dart | 7 ++++- lib/ui2/profile/devices.dart | 11 +++++-- pubspec.lock | 24 +++++++-------- pubspec.yaml | 11 ++++++- test/adapters/ringconn_adapter_test.dart | 15 ++++++---- 6 files changed, 74 insertions(+), 31 deletions(-) diff --git a/lib/ble/adapters/ringconn.dart b/lib/ble/adapters/ringconn.dart index 23178a5fa..73e96f651 100644 --- a/lib/ble/adapters/ringconn.dart +++ b/lib/ble/adapters/ringconn.dart @@ -109,6 +109,14 @@ class RingConnAdapter extends BandAdapter { // 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(); @@ -184,7 +192,6 @@ class RingConnAdapter extends BandAdapter { _Inbox inbox, int channel, ) async* { - final raw = []; final cursor = ringConnCursor(nowSeconds()); if (!await link.write( kRingConnCommandChar, @@ -192,23 +199,24 @@ class RingConnAdapter extends BandAdapter { )) { link.log('ringconn: sync-open refused on channel $channel; skipping ' 'it.'); - if (raw.isNotEmpty) yield SampleBatch(const [], raw: raw); return; } + final syncOpenRaw = []; final opened = await inbox.firstWhere( (f) => f.respid == kRingConnRespSyncOpen, replyTimeout, - onEach: raw.add, + 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.'); - if (raw.isNotEmpty) yield SampleBatch(const [], raw: raw); return; } if (!await link.write(kRingConnCommandChar, ringConnCmdFetch())) { link.log('ringconn: fetch refused on channel $channel; ending its ' 'drain.'); - if (raw.isNotEmpty) yield SampleBatch(const [], raw: raw); return; } @@ -221,7 +229,14 @@ class RingConnAdapter extends BandAdapter { break; } final (f, bytes) = rec; - raw.add(bytes); + // 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 @@ -252,7 +267,6 @@ class RingConnAdapter extends BandAdapter { '$channel with no burst-end reply; stopping this session\'s ' 'drain there.'); } - if (raw.isNotEmpty) yield SampleBatch(const [], raw: raw); } } @@ -287,6 +301,15 @@ class _Inbox { 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)); diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 853131793..415845ee2 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1726,8 +1726,13 @@ 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. const String kAnalyticsPin = '1fa8144a5e3b728ce91eeed6ecbc15d482933b44'; -const String kProtocolPin = '471034cb84b85edb37e72b6f6add79a2d7929294'; +const String kProtocolPin = 'b819ee09fe2248525c308ef9d26abf28033efdc8'; // Fold idempotency, the minimum-nights warm-up, and legacy-payload handling // all live in SleepProfilePolicy (pure, unit-tested) — see diff --git a/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart index a151b87e7..a5736335e 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -47,6 +47,7 @@ import '../../ble/adapters/_registry.dart' import '../../ble/adapters/signals.dart' show InputSignal; import '../../ble/hrs_link.dart' show HrsLink, HrsReading; import '../../ble/oura_link.dart' show OuraLink, pairOuraRing; +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; @@ -1491,7 +1492,9 @@ class _DeviceDetailState extends State { // primary band's link, its restore identity and its trim cursor, none of // which a sensor has — pointing this at it would have unpaired the // WHOOP from a chest strap's page. - onSync: s.family == 'oura' ? () => _syncRing(c) : null, + onSync: s.family == 'oura' || s.family == 'ringconn' + ? () => _syncRing(c, s.family) + : null, onForget: s.deviceId != null ? () => _confirmForgetSensor(c, s) : app == null @@ -1504,12 +1507,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 599e0ac0d..6af308da7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -857,10 +857,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mgrs_dart: dependency: transitive description: @@ -945,11 +945,9 @@ packages: openstrap_protocol: dependency: "direct main" description: - path: "." - ref: "471034cb84b85edb37e72b6f6add79a2d7929294" - resolved-ref: "471034cb84b85edb37e72b6f6add79a2d7929294" - url: "https://github.com/OpenStrap/protocol.git" - source: git + path: "/tmp/protocol-ringconn-wt" + relative: false + source: path version: "1.0.0" ota_update: dependency: "direct main" @@ -1376,26 +1374,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.16" timezone: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 98c2f6cb8..775d7655c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -143,7 +143,16 @@ dependencies: # 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. - ref: 471034cb84b85edb37e72b6f6add79a2d7929294 + # + # 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: b819ee09fe2248525c308ef9d26abf28033efdc8 openstrap_analytics: git: url: https://github.com/OpenStrap/analytics.git diff --git a/test/adapters/ringconn_adapter_test.dart b/test/adapters/ringconn_adapter_test.dart index 224a9068c..dfcc0661b 100644 --- a/test/adapters/ringconn_adapter_test.dart +++ b/test/adapters/ringconn_adapter_test.dart @@ -224,12 +224,13 @@ void main() { // fetch is answered the same way by this script) drains two more. expect(acks, 2); final batches = events.whereType().toList(); - // auth (1) + sleep-channel (1) + awake-channel (1). - expect(batches, hasLength(3)); + // 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); - // sync-open ack + page 1 (fetch) + page 2 (ack reply) = 3 frames. - expect(b.raw, hasLength(3)); + expect(b.raw, hasLength(1)); } final page2 = _bulkPage( kRingConnRespBulkActivity, @@ -238,7 +239,7 @@ void main() { [0x02], ); expect( - batches[1].raw!.last, + batches.last.raw!.single, Uint8List.fromList(page2), reason: 'the last page reaches raw verbatim, header included', ); @@ -266,7 +267,9 @@ void main() { return const []; }); final batches = events.whereType().toList(); - expect(batches, hasLength(3)); + // 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); } From 02ac02e1fbb2cf951ffd2c4296234e5ceeda869f Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:47:57 +0530 Subject: [PATCH 4/5] ringconn: fix pubspec.lock resolving protocol from a local path A pub get run with pubspec_overrides.yaml present after the repin rewrote the lock back to the local worktree path, which is exactly what the CI guard script exists to catch. --- pubspec.lock | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 6af308da7..ec2408156 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -945,9 +945,11 @@ packages: openstrap_protocol: dependency: "direct main" description: - path: "/tmp/protocol-ringconn-wt" - relative: false - source: path + path: "." + ref: b819ee09fe2248525c308ef9d26abf28033efdc8 + resolved-ref: b819ee09fe2248525c308ef9d26abf28033efdc8 + url: "https://github.com/OpenStrap/protocol.git" + source: git version: "1.0.0" ota_update: dependency: "direct main" From 9d479ab7a105aab4df286dd0229e63928877e74d Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:13:43 +0530 Subject: [PATCH 5/5] ringconn: gate the page ack behind a commit, not a bare write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page ack is what advances the ring's own resume pointer, and until now the adapter sent it directly after yielding the page — the yield only buffers the raw bytes in the host's memory, durable commit is a separate periodic flush. A crash between the two would have let the ring move past a page nothing ever wrote to disk. Route the ack through OffloadCheckpoint's confirm callback, same contract oura.dart already uses, so the host commits before the ack goes out. --- lib/ble/adapters/ringconn.dart | 15 ++++++++++++++- test/adapters/ringconn_adapter_test.dart | 8 +++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/ble/adapters/ringconn.dart b/lib/ble/adapters/ringconn.dart index 73e96f651..3ea886fad 100644 --- a/lib/ble/adapters/ringconn.dart +++ b/lib/ble/adapters/ringconn.dart @@ -246,7 +246,20 @@ class RingConnAdapter extends BandAdapter { final ack = f.respid == kRingConnRespBulkPpg ? ringConnCmdAckPpg() : ringConnCmdAckActivity(); - if (!await link.write(kRingConnCommandChar, ack)) { + // 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; diff --git a/test/adapters/ringconn_adapter_test.dart b/test/adapters/ringconn_adapter_test.dart index dfcc0661b..674418482 100644 --- a/test/adapters/ringconn_adapter_test.dart +++ b/test/adapters/ringconn_adapter_test.dart @@ -84,7 +84,13 @@ Future<(List, ReplayBandLink)> _drive( final events = []; final done = Completer(); final sub = adapter.run(link).listen( - (e) => events.add(e), + (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;