Skip to content

Commit 189b552

Browse files
authored
Merge pull request #361 from OpenStrap/feat/garmin-gfdi-adapter
garmin sports watch pairing
2 parents b20abbd + 3441c1d commit 189b552

12 files changed

Lines changed: 831 additions & 4 deletions

lib/ble/adapters/_registry.dart

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,19 @@ 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+
/// Garmin's Multi-Link service — one characteristic pair carries every
143+
/// logical service (GFDI, the numbered real-time streams) this device family
144+
/// speaks, routed by a handle byte. See `protocol`'s `garmin.dart`.
145+
const String kGarminService = '6a4e2800-667b-11e3-949a-0800200c9a66';
146+
147+
/// Host to watch. Every ML control frame and every GFDI/COBS chunk is
148+
/// written here, with response.
149+
const String kGarminWriteChar = '6a4e2820-667b-11e3-949a-0800200c9a66';
150+
151+
/// Watch to host. Every ML control reply and every GFDI/COBS chunk arrives
152+
/// here — there is no separate data pipe.
153+
const String kGarminNotifyChar = '6a4e2810-667b-11e3-949a-0800200c9a66';
154+
142155
/// The Ultrahuman Ring Air's command/response service. The primary service —
143156
/// `BandEntry.notify` points at this one, not the device-state service below.
144157
const String kUltrahumanCommandService = '86f65000-f706-58a0-95b2-1fb9261e4dc7';
@@ -775,6 +788,30 @@ const BandEntry kOura = BandEntry.notify(
775788
timeAnchor: TimeAnchor.arrival,
776789
);
777790

791+
/// A Garmin sports watch (GFDI v2), paired through the watch's own
792+
/// Settings -> Sensors & Accessories -> Phone -> Pair Phone menu.
793+
///
794+
/// NOT framed: there is a frame length and a CRC, but they sit inside a COBS
795+
/// byte stream carried on a Multi-Link handle rather than directly on the
796+
/// characteristic the way [BandProfile] models — a different reassembly
797+
/// shape [innerOpcodeOffset] etc. could not describe. See `protocol`'s
798+
/// `garmin.dart` for the wire format itself.
799+
///
800+
/// EXPERIMENTAL, and it stays that way: nobody on this project owns a Garmin
801+
/// watch, so not a byte of this path has met hardware (ASSUMPTIONS R6).
802+
/// `signals` is `const {}` and this id is absent from `kDerivableSources`
803+
/// a paired watch answers a device-info push and one battery request, and
804+
/// surfaces no health signal at all.
805+
const BandEntry kGarmin = BandEntry.notify(
806+
id: 'garmin',
807+
label: 'Garmin watch',
808+
service: kGarminService,
809+
characteristics: <String>[kGarminWriteChar, kGarminNotifyChar],
810+
// No clock this build reads back; the watch's own GFDI clock is what
811+
// CURRENT_TIME_REQUEST answers, not something read into a stored sample.
812+
timeAnchor: TimeAnchor.arrival,
813+
);
814+
778815
/// The Ultrahuman Ring Air. A fetch-by-index band with no auth and no
779816
/// envelope: a bare `[opcode, ...body]` request and a
780817
/// `[opcode, result, count, payload…, trailer(2)]` response, both on ONE
@@ -1412,6 +1449,7 @@ const List<BandEntry> kBandRegistry = <BandEntry>[
14121449
kJyou,
14131450
kWatch9,
14141451
kBangleJs,
1452+
kGarmin,
14151453
];
14161454

14171455
/// The bands the OFFLOAD ENGINE can drive, and the bands iOS provisions
@@ -1497,6 +1535,7 @@ const Map<String, Map<InputSignal, Duration>> kAdapterSignals =
14971535
'jyou': <InputSignal, Duration>{},
14981536
'watch9': <InputSignal, Duration>{},
14991537
'banglejs': <InputSignal, Duration>{},
1538+
'garmin': <InputSignal, Duration>{},
15001539
};
15011540

15021541
/// The signals one adapter declares, or empty for an id this build has no

lib/ble/adapters/garmin.dart

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
// A Garmin sports watch as a [BandAdapter]: open the Multi-Link control
2+
// channel, register the GFDI handle, answer what the watch needs answered to
3+
// stay connected, ask for battery once, bank every frame verbatim.
4+
//
5+
// NOTHING HERE HAS MET HARDWARE. Nobody on this project owns a Garmin watch,
6+
// so it ships EXPERIMENTAL (ASSUMPTIONS R6): `signals` is `const {}` and
7+
// `garmin` is absent from `kDerivableSources` — this session never turns a
8+
// byte into a heart rate, a step count or a sleep stage.
9+
//
10+
// THIS IS A BOUNDED SESSION, not a drain and not arm/disarm — same shape as
11+
// `dafit.dart`'s own choice and for the same reason: there is no stored
12+
// history this pass decodes, so there is no natural end-of-transfer signal.
13+
// Long enough to open the channel, receive the unprompted device-info push,
14+
// and get one battery answer back; short enough to fit the same background
15+
// wake slot as the primary band's own sync.
16+
//
17+
// WHY REGISTER_ML'S OWN REFUSAL IS THE DECLINE SIGNAL, not a REGISTRATION
18+
// service capability query. Per-firmware variance in which services a watch
19+
// exposes is real, but a query for it needs its own wire format to trust —
20+
// and unlike the messages below, that one is not corroborated cleanly enough
21+
// to build. Refusing to guess it is the honest floor: a watch that has no
22+
// GFDI to give answers REGISTER_ML with a non-zero status, and this file
23+
// declines cleanly on exactly that, never on an assumption that GFDI exists.
24+
//
25+
// ACKING IS UNCONDITIONAL FOR ANY INBOUND MESSAGE OTHER THAN A RESPONSE
26+
// ITSELF, and it is a bring-up requirement, not politeness — an unacked
27+
// data-bearing message is documented to leave the watch stalled. Answering
28+
// CURRENT_TIME_REQUEST is the same kind of requirement, not a nicety.
29+
30+
import 'dart:async';
31+
import 'dart:typed_data';
32+
33+
import 'package:openstrap_protocol/openstrap_protocol.dart';
34+
35+
import '_registry.dart';
36+
import 'adapter.dart';
37+
import 'signals.dart';
38+
39+
/// The request id this pass's one outstanding protobuf ask carries. A single
40+
/// fixed value is enough: this session never has two requests in flight.
41+
const int _kBatteryRequestId = 1;
42+
43+
int _defaultNowSeconds() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
44+
int _defaultUtcOffsetSeconds() => DateTime.now().timeZoneOffset.inSeconds;
45+
46+
class GarminAdapter extends BandAdapter {
47+
/// Wall-clock now, and this phone's current UTC offset — both injected so a
48+
/// fixture replay is deterministic.
49+
final int Function() nowSeconds;
50+
final int Function() utcOffsetSeconds;
51+
52+
/// How long to wait for CLOSE_ALL_RESP or REGISTER_ML_RESP before giving up.
53+
final Duration handshakeTimeout;
54+
55+
/// How long the session stays open once the GFDI channel is registered —
56+
/// see the header note on why this is bounded rather than open-ended.
57+
final Duration sessionWindow;
58+
59+
const GarminAdapter({
60+
this.nowSeconds = _defaultNowSeconds,
61+
this.utcOffsetSeconds = _defaultUtcOffsetSeconds,
62+
this.handshakeTimeout = const Duration(seconds: 5),
63+
this.sessionWindow = const Duration(seconds: 8),
64+
});
65+
66+
@override
67+
BandEntry get entry => kGarmin;
68+
69+
/// NOTHING. See the header note — this pass decodes device identity and a
70+
/// battery level, neither of which is a physiological signal, and nothing
71+
/// else on the wire is touched.
72+
@override
73+
Map<InputSignal, Duration> get signals => const {};
74+
75+
/// COBS-encode and Multi-Link-frame one outbound GFDI frame, then write it.
76+
/// False for a handle this session never registered, or a refused write —
77+
/// both non-fatal to the caller, which only logs and moves on.
78+
Future<bool> _sendGfdi(BandLink link, int? handle, Uint8List frame) async {
79+
if (handle == null) return false;
80+
try {
81+
final framed = garminEncodeTx(handle, garminCobsEncode(frame));
82+
return await link.write(kGarminWriteChar, framed);
83+
} on ArgumentError {
84+
return false; // a handle outside the addressable range; refuse, don't crash
85+
}
86+
}
87+
88+
@override
89+
Stream<BandEvent> run(BandLink link) async* {
90+
final cobs = GarminCobsReassembler();
91+
final events = StreamController<BandEvent>();
92+
final archived = <Uint8List>[];
93+
final closeAllDone = Completer<bool>();
94+
final registerDone = Completer<GarminRegisterMlResponse?>();
95+
int? gfdiHandle;
96+
97+
Future<void> ackAndDispatch(GarminGfdiFrame f) async {
98+
if (f.type != kGarminMsgResponse) {
99+
await _sendGfdi(link, gfdiHandle, garminBuildStatusAck(f.type));
100+
}
101+
switch (f.type) {
102+
case kGarminMsgCurrentTimeRequest:
103+
await _sendGfdi(
104+
link,
105+
gfdiHandle,
106+
garminBuildTimeResponse(
107+
nowUnixSeconds: nowSeconds(),
108+
utcOffsetSeconds: utcOffsetSeconds(),
109+
),
110+
);
111+
case kGarminMsgDeviceInformation:
112+
final info = garminParseDeviceInformation(f);
113+
if (info != null && !events.isClosed) {
114+
final model =
115+
info.deviceModel.isNotEmpty ? info.deviceModel : info.deviceName;
116+
if (model.isNotEmpty) events.add(BandNote('model', model));
117+
events.add(BandNote('firmware', info.firmware));
118+
}
119+
case kGarminMsgProtobufResponse:
120+
final pf = garminParseProtobufFrame(f);
121+
if (pf == null) break;
122+
if (!pf.isComplete) {
123+
link.log('garmin: ignoring a chunked protobuf reply (offset '
124+
'${pf.dataOffset} of ${pf.totalLength} bytes).');
125+
break;
126+
}
127+
if (pf.requestId != _kBatteryRequestId) break;
128+
final battery = garminParseBatteryResponseProto(pf.protoBytes);
129+
if (battery != null && !events.isClosed) {
130+
events.add(BandNote('battery', battery.level));
131+
}
132+
case kGarminMsgSystemEvent:
133+
final ev = garminParseSystemEvent(f);
134+
if (ev != null) {
135+
link.log('garmin: system event ${ev.$1} (value ${ev.$2}).');
136+
}
137+
}
138+
}
139+
140+
void onDisconnected() {
141+
if (!closeAllDone.isCompleted) closeAllDone.complete(false);
142+
if (!registerDone.isCompleted) registerDone.complete(null);
143+
if (!events.isClosed) events.close();
144+
}
145+
146+
final sub = link.notify(kGarminNotifyChar).listen(
147+
(rec) {
148+
final bytes = Uint8List.fromList(rec.$2);
149+
final decoded = garminDecodeMlr(bytes);
150+
if (decoded is GarminCloseAllAck) {
151+
if (!closeAllDone.isCompleted) closeAllDone.complete(true);
152+
return;
153+
}
154+
if (decoded is GarminRegisterMlResponse) {
155+
if (!registerDone.isCompleted) registerDone.complete(decoded);
156+
return;
157+
}
158+
final handle = gfdiHandle;
159+
if (decoded is GarminMlrData && handle != null && decoded.handle == handle) {
160+
// Byte 0 is the routing byte; the COBS/GFDI stream starts after it.
161+
for (final frame in cobs.feed(decoded.payload.sublist(1))) {
162+
archived.add(frame);
163+
final gfdi = garminParseGfdiFrame(frame);
164+
if (gfdi != null) unawaited(ackAndDispatch(gfdi));
165+
}
166+
return;
167+
}
168+
// Control-channel noise this file has no decode for, or data on a
169+
// handle this session never registered — banked, never acted on.
170+
archived.add(bytes);
171+
},
172+
onDone: onDisconnected,
173+
onError: (Object _) => onDisconnected(),
174+
);
175+
176+
try {
177+
if (!await link.write(
178+
kGarminWriteChar, garminEncodeTx(0, garminCloseAllRequest()))) {
179+
link.log('garmin: close-all write refused; ending the session.');
180+
return;
181+
}
182+
final closed =
183+
await closeAllDone.future.timeout(handshakeTimeout, onTimeout: () => false);
184+
if (!closed) {
185+
link.log('garmin: no CLOSE_ALL acknowledgement — the watch is '
186+
'probably still connected to a phone.');
187+
return;
188+
}
189+
190+
if (!await link.write(kGarminWriteChar,
191+
garminEncodeTx(0, garminRegisterMlRequest(kGarminServiceGfdi)))) {
192+
link.log('garmin: register-ml write refused; ending the session.');
193+
return;
194+
}
195+
final reg = await registerDone.future
196+
.timeout(handshakeTimeout, onTimeout: () => null);
197+
if (reg == null || !reg.accepted) {
198+
link.log('garmin: the watch declined the GFDI channel (status '
199+
'${reg?.status ?? "none"}).');
200+
return;
201+
}
202+
gfdiHandle = reg.handle;
203+
204+
await _sendGfdi(
205+
link,
206+
gfdiHandle,
207+
garminBuildProtobufRequest(
208+
requestId: _kBatteryRequestId,
209+
protoBytes: garminBatteryRequestProto(),
210+
),
211+
);
212+
213+
final timer = Timer(sessionWindow, () {
214+
if (!events.isClosed) events.close();
215+
});
216+
try {
217+
yield* events.stream;
218+
} finally {
219+
timer.cancel();
220+
}
221+
} finally {
222+
await sub.cancel();
223+
if (!events.isClosed) events.close();
224+
}
225+
if (archived.isNotEmpty) {
226+
yield SampleBatch(const [], raw: List.of(archived));
227+
}
228+
}
229+
}
230+
231+
/// The single instance. Const, so it costs nothing to reference.
232+
const GarminAdapter kGarminAdapter = GarminAdapter();

0 commit comments

Comments
 (0)