Skip to content

Commit 9b06d45

Browse files
authored
Merge pull request #362 from OpenStrap/feat/coros-sensor-adapter
add coros sports watch support
2 parents 189b552 + b700ede commit 9b06d45

14 files changed

Lines changed: 583 additions & 6 deletions

lib/ble/adapters/_registry.dart

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,24 @@ const String kOuraCommandChar = '98ed0002-a541-11e4-b6a0-0002a5d5c51b';
139139
/// event share this one characteristic — there is no separate data pipe.
140140
const String kOuraNotifyChar = '98ed0003-a541-11e4-b6a0-0002a5d5c51b';
141141

142+
/// One of the Nordic-UART-shaped 128-bit services a Coros watch exposes
143+
/// alongside the standard SIG services below — used here only as the scan
144+
/// filter, since a bare `0000180d` (heart rate) would collide with
145+
/// [kBleHrs] and get shadowed by it (that entry is matched first). NOT
146+
/// independently confirmed against a real advertisement payload (post-connect
147+
/// service enumeration is documented; the advertised UUID list is not) — see
148+
/// `coros.dart`'s own header before trusting this against real hardware.
149+
const String kCorosService = '6e400001-b5a3-f393-e0a9-77656c6f6f70';
150+
151+
/// Standard Battery Service characteristic, read+notify, one byte 0-100.
152+
const String kBatteryLevelUuid = '00002a19-0000-1000-8000-00805f9b34fb';
153+
154+
/// Standard Device Information Service characteristics — read-only UTF-8
155+
/// strings, no notify property.
156+
const String kModelNumberUuid = '00002a24-0000-1000-8000-00805f9b34fb';
157+
const String kSerialNumberUuid = '00002a25-0000-1000-8000-00805f9b34fb';
158+
const String kFirmwareRevisionUuid = '00002a26-0000-1000-8000-00805f9b34fb';
159+
142160
/// Garmin's Multi-Link service — one characteristic pair carries every
143161
/// logical service (GFDI, the numbered real-time streams) this device family
144162
/// speaks, routed by a handle byte. See `protocol`'s `garmin.dart`.
@@ -788,6 +806,35 @@ const BandEntry kOura = BandEntry.notify(
788806
timeAnchor: TimeAnchor.arrival,
789807
);
790808

809+
/// A Coros sports watch (Pace/Apex/Vertix series): every standard GATT
810+
/// service answers a plain connect, no pairing or bonding enforced.
811+
///
812+
/// NOT framed: no envelope, no command channel, no offload — see the header
813+
/// note on why activity/sleep/step history stays out of scope entirely.
814+
///
815+
/// `characteristics` IS BATTERY ALONE, deliberately. The Bluetooth SIG's
816+
/// Device Information Service marks model/serial/firmware as OPTIONAL —
817+
/// gating the connect on any of them is how a real watch that simply omits
818+
/// one string fails `missingCharacteristics` and never connects at all.
819+
/// `CorosAdapter._readString` already answers null for a characteristic that
820+
/// is not there; the honest gate is the one characteristic every watch in
821+
/// scope should answer. Heart rate is read via [kHeartRateMeasurementUuid]
822+
/// directly in `coros.dart` and is equally NOT required here, for the same
823+
/// reason: a watch that answers battery and identity but not heart rate
824+
/// should still connect.
825+
///
826+
/// EXPERIMENTAL, and it stays that way: nobody on this project owns one, so
827+
/// not a byte of this path has met hardware (ASSUMPTIONS R6). `signals` is
828+
/// `const {}`-equivalent territory for anything but the generic HR parse —
829+
/// `kDerivableSources` stays empty regardless, same as every other band here.
830+
const BandEntry kCoros = BandEntry.notify(
831+
id: 'coros',
832+
label: 'Coros watch',
833+
service: kCorosService,
834+
characteristics: <String>[kBatteryLevelUuid],
835+
timeAnchor: TimeAnchor.arrival,
836+
);
837+
791838
/// A Garmin sports watch (GFDI v2), paired through the watch's own
792839
/// Settings -> Sensors & Accessories -> Phone -> Pair Phone menu.
793840
///
@@ -1425,6 +1472,7 @@ const List<BandEntry> kBandRegistry = <BandEntry>[
14251472
kWhoopGen5,
14261473
kBleHrs,
14271474
kOura,
1475+
kCoros,
14281476
kUltrahuman,
14291477
kWithingsSteelHr,
14301478
kMiBand234,
@@ -1511,6 +1559,10 @@ const Map<String, Map<InputSignal, Duration>> kAdapterSignals =
15111559
InputSignal.rrIntervals: Duration(seconds: 1),
15121560
},
15131561
'oura': <InputSignal, Duration>{},
1562+
'coros': {
1563+
InputSignal.hrSparse: Duration(seconds: 1),
1564+
InputSignal.rrIntervals: Duration(seconds: 1),
1565+
},
15141566
'ultrahuman': <InputSignal, Duration>{},
15151567
'withings_steel_hr': <InputSignal, Duration>{},
15161568
'miband234': <InputSignal, Duration>{},

lib/ble/adapters/adapter.dart

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,15 @@ abstract class BandLink {
7676
/// The whole surface used to be `notify`/`write`/`log`, and every band so
7777
/// far only ever needed to be WRITTEN to or NOTIFIED by. RingConn's auth
7878
/// needs one plain GATT read (the standard System ID characteristic, to
79-
/// recover its own BLE MAC) that no framed band and no notify-only sensor
80-
/// before it required — see `ringconn.dart`.
79+
/// recover its own BLE MAC), and Coros's status pull needs several more
80+
/// (battery, model, serial, firmware) — the first two bands that needed a
81+
/// one-shot read where no framed band and no notify-only sensor before them
82+
/// required one. See `ringconn.dart` and `coros.dart`.
83+
///
84+
/// For a characteristic with no notify property (Device Information
85+
/// Service's read-only strings, say) this is the only way to reach it —
86+
/// [notify] stays the right call for anything that can push updates on its
87+
/// own.
8188
Future<List<int>?> read(String characteristicUuid);
8289

8390
/// Write with response, which is also what triggers bonding. Returns whether

lib/ble/adapters/coros.dart

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// A Coros sports watch (Pace/Apex/Vertix series) as a [BandAdapter]: a
2+
// one-shot status pull (battery, model, serial, firmware) at connect, then
3+
// the same generic 0x2A37 heart-rate parse [BleHrsAdapter] already has.
4+
//
5+
// SCOPE IS DELIBERATELY NARROW. Every standard SIG service on this watch
6+
// answers a plain connect with no pairing or bonding enforced — battery,
7+
// device information and heart rate are all documented, unencrypted GATT.
8+
// Recorded activity, sleep and step history ride a completely undocumented
9+
// proprietary channel with no public frame spec anywhere; decoding it would
10+
// mean inventing a physiological data format from nothing, which is exactly
11+
// what this project refuses to do. That channel is never touched here.
12+
//
13+
// NOTHING HERE HAS MET HARDWARE. Nobody on this project owns one, so ships
14+
// EXPERIMENTAL (ASSUMPTIONS R6): `signals` covers only the generic HR parse,
15+
// and `coros` stays absent from `kDerivableSources` like every other band —
16+
// see `_registry.dart`'s `kCoros` doc on the one real unknown (the exact
17+
// advertised service UUID) that still needs a real device to close.
18+
19+
import 'dart:convert';
20+
21+
import 'package:openstrap_protocol/openstrap_protocol.dart';
22+
23+
import '_registry.dart';
24+
import 'adapter.dart';
25+
import 'signals.dart';
26+
27+
/// The adapter. Const, and it holds no session state — everything a session
28+
/// needs lives inside [run].
29+
class CorosAdapter extends BandAdapter {
30+
const CorosAdapter();
31+
32+
@override
33+
BandEntry get entry => kCoros;
34+
35+
/// The generic HR parse only. Nothing else on this watch is decoded —
36+
/// battery and device identity are [BandNote]s, not a physiological signal.
37+
@override
38+
Map<InputSignal, Duration> get signals => const {
39+
InputSignal.hrSparse: Duration(seconds: 1),
40+
InputSignal.rrIntervals: Duration(seconds: 1),
41+
};
42+
43+
@override
44+
Stream<BandEvent> run(BandLink link) async* {
45+
// One-shot, best-effort: a watch that answers battery and identity but
46+
// not heart rate (or the reverse) still gets whatever it does answer —
47+
// see `kCoros`'s own doc on why none of this is required to connect.
48+
final battery = await link.read(kBatteryLevelUuid);
49+
if (battery != null && battery.isNotEmpty) {
50+
yield BandNote('battery', battery[0]);
51+
}
52+
final model = await _readString(link, kModelNumberUuid);
53+
if (model != null) yield BandNote('model', model);
54+
final serial = await _readString(link, kSerialNumberUuid);
55+
if (serial != null) yield BandNote('serial', serial);
56+
final firmware = await _readString(link, kFirmwareRevisionUuid);
57+
if (firmware != null) yield BandNote('firmware', firmware);
58+
59+
// No handshake for HR itself — same floor as `BleHrsAdapter`: one
60+
// subscription, no clock, no INIT.
61+
await for (final (atSec, value) in link.notify(kHeartRateMeasurementUuid)) {
62+
final s = parseHeartRateMeasurement(value);
63+
if (s == null) continue;
64+
// The sensor's own "no skin contact" is a REFUSAL, not a low reading —
65+
// see `ble_hrs.dart`'s identical guard.
66+
if (s.contact == false) continue;
67+
yield SampleBatch(
68+
[
69+
NeutralSample(
70+
// ARRIVAL TIME, NOT BEAT TIME — see `ble_hrs.dart`'s doc on why.
71+
anchor: TimeAnchor.arrival,
72+
tsEpoch: atSec,
73+
hr: s.hr,
74+
rrMs: s.rrMs,
75+
vendor: s.contact == null ? const {} : {'contact': s.contact},
76+
),
77+
],
78+
ephemeral: false,
79+
);
80+
}
81+
// No OffloadCheckpoint, ever. This watch's recorded history is never
82+
// requested — see the header note — so there is nothing to tell it to
83+
// forget.
84+
}
85+
86+
/// A read-only Device Information string, or null for a missing
87+
/// characteristic, an empty reply or one that decodes to nothing.
88+
Future<String?> _readString(BandLink link, String uuid) async {
89+
final bytes = await link.read(uuid);
90+
if (bytes == null || bytes.isEmpty) return null;
91+
final s = utf8.decode(bytes, allowMalformed: true).trim();
92+
return s.isEmpty ? null : s;
93+
}
94+
}
95+
96+
/// The single instance. Const, so it costs nothing to reference.
97+
const CorosAdapter kCorosAdapter = CorosAdapter();

lib/ble/adapters/gatt_link.dart

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,18 @@ class GattBandLink implements BandLink {
9595
static const Duration _notifyTimeout = Duration(seconds: 15);
9696
static const Duration _writeTimeout = Duration(seconds: 8);
9797

98+
/// [read]'s own bound — deliberately shorter than [_notifyTimeout]. A
99+
/// one-shot status pull (Coros's battery/device-info reads) can run several
100+
/// of these BACK TO BACK before a session's bounded window ever reaches the
101+
/// notify phase; at 15s each, four sequential reads could burn a full
102+
/// minute on one unresponsive characteristic before the caller's own
103+
/// session timeout even has a chance to matter. 2s is still generous for a
104+
/// live GATT round trip, and it is what lets a caller doing four of these
105+
/// (Coros's status pull) size its own session window with real seconds left
106+
/// over for whatever comes after the reads, even in the fully-unresponsive
107+
/// worst case.
108+
static const Duration _readTimeout = Duration(seconds: 2);
109+
98110
/// One write in flight at a time — the same [WriteChain] `BleEngine._write`
99111
/// runs on, one instance per link. Not shared with the engine's: two
100112
/// peripherals queueing behind each other is exactly what the per-remoteId
@@ -195,10 +207,15 @@ class GattBandLink implements BandLink {
195207
return null;
196208
}
197209
try {
198-
return await c.read().timeout(_notifyTimeout);
199-
} on TimeoutException {
200-
log('read timeout: no GATT response in ${_notifyTimeout.inSeconds}s.');
201-
return null;
210+
// The timeout goes INTO the call, not wrapped around it. flutter_blue_plus
211+
// serialises every GATT operation behind one global mutex and only
212+
// releases it when the operation's OWN future settles — an outer
213+
// `Future.timeout` does not cancel that future, so a wrapped read still
214+
// held the mutex (and the platform channel) for its internal default of
215+
// 15s regardless of how quickly this method gave up on it, and every
216+
// other BLE op on this phone — including the primary band's — queues
217+
// behind that same mutex.
218+
return await c.read(timeout: _readTimeout.inSeconds);
202219
} catch (e) {
203220
log('read error: $e');
204221
return null;

0 commit comments

Comments
 (0)