|
| 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