Skip to content

Commit 6fad0c7

Browse files
committed
merge main
2 parents 014debb + 28c702c commit 6fad0c7

124 files changed

Lines changed: 19515 additions & 73 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

lib/ble/adapters/_registry.dart

Lines changed: 1128 additions & 5 deletions
Large diffs are not rendered by default.

lib/ble/adapters/adapter.dart

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,23 @@ abstract class BandLink {
7070
/// [BandEntry.requiredCharacteristics], where the connect aborts loudly.
7171
Stream<(int atSec, List<int> value)> notify(String characteristicUuid);
7272

73+
/// Read a characteristic once. Returns null on a missing characteristic, a
74+
/// dead link, or a timeout — the same failure vocabulary [write] uses.
75+
///
76+
/// The whole surface used to be `notify`/`write`/`log`, and every band so
77+
/// far only ever needed to be WRITTEN to or NOTIFIED by. RingConn's auth
78+
/// needs one plain GATT read (the standard System ID characteristic, to
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.
88+
Future<List<int>?> read(String characteristicUuid);
89+
7390
/// Write with response, which is also what triggers bonding. Returns whether
7491
/// the GATT write was confirmed; false covers a missing characteristic, a
7592
/// dead link, a timeout and a refused opcode alike.
@@ -299,6 +316,12 @@ class ReplayBandLink implements BandLink {
299316
/// What [write] returns. Set false to exercise an adapter's failure path.
300317
bool writeSucceeds = true;
301318

319+
/// Extra delay before [write] resolves. Zero unless a test sets it — for
320+
/// exercising a race against a write that is still genuinely in flight
321+
/// (the real `GattBandLink._writeTimeout` is 8s, long enough to still be
322+
/// pending when a session's own teardown starts).
323+
Duration writeDelay = Duration.zero;
324+
302325
/// Single-subscription on purpose: it BUFFERS, so a fixture may be fed
303326
/// before the adapter has got around to subscribing and nothing is dropped.
304327
/// A second `notify()` of the same characteristic throws, which is correct —
@@ -310,9 +333,26 @@ class ReplayBandLink implements BandLink {
310333
Stream<(int, List<int>)> notify(String characteristicUuid) =>
311334
_channel(characteristicUuid).stream;
312335

336+
/// Whether anything is still listening to [characteristicUuid]'s stream.
337+
/// Test-only: the way to prove a multi-channel adapter actually cancels
338+
/// every upstream subscription it opened, not just the one it names in its
339+
/// own `finally`.
340+
bool isListening(String characteristicUuid) =>
341+
_channels[characteristicUuid]?.hasListener ?? false;
342+
343+
/// What [read] answers, by characteristic uuid. A test sets this before
344+
/// exercising the adapter; an uuid with no entry answers null, same as a
345+
/// real link's missing-characteristic case.
346+
final Map<String, List<int>> readValues = {};
347+
348+
@override
349+
Future<List<int>?> read(String characteristicUuid) async =>
350+
readValues[characteristicUuid];
351+
313352
@override
314353
Future<bool> write(String characteristicUuid, List<int> value) async {
315354
writes.add((characteristicUuid, value));
355+
if (writeDelay > Duration.zero) await Future<void>.delayed(writeDelay);
316356
return writeSucceeds;
317357
}
318358

@@ -344,4 +384,12 @@ class ReplayBandLink implements BandLink {
344384
}
345385
_channels.clear();
346386
}
387+
388+
/// End one channel while the others stay open — for an adapter test that
389+
/// needs to reproduce "one channel ends while another still has a frame
390+
/// in flight" rather than a full teardown.
391+
Future<void> closeChannel(String uuid) async {
392+
final c = _channels.remove(uuid);
393+
if (c != null) await c.close();
394+
}
347395
}

lib/ble/adapters/banglejs.dart

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Bangle.js as a [BandAdapter] — pair, connect, bank raw bytes. Nothing else.
2+
//
3+
// Bangle.js has no byte-level record protocol of its own. It exposes Nordic's
4+
// UART Service, a generic serial-over-BLE pipe, behind which runs a full
5+
// Espruino JavaScript REPL: the phone writes JS source text, the watch
6+
// executes it and prints text back. Activity/HR/notification data only
7+
// exists as JSON lines an OPTIONAL, user-installed, third-party JS app can be
8+
// made to emit — that app's message schema is not a firmware-level fact, it
9+
// is a moving target owned by a different, independently-versioned project a
10+
// given watch may or may not be running. So this adapter never parses a line,
11+
// never assumes one was even printed, and never writes to the RX
12+
// characteristic: there is nothing to hand it that would not itself be
13+
// evaluated as executable code.
14+
//
15+
// NOTHING HERE HAS MET HARDWARE (ASSUMPTIONS R6). It ships EXPERIMENTAL: no
16+
// signal, no decode, every chunk lands in `raw_archive` verbatim and stays
17+
// there until someone owning real hardware writes and verifies a decoder
18+
// against a real capture.
19+
20+
import 'dart:typed_data';
21+
22+
import '_registry.dart';
23+
import 'adapter.dart';
24+
import 'signals.dart';
25+
26+
/// The adapter. Const, and it holds no session state.
27+
class BangleJsAdapter extends BandAdapter {
28+
const BangleJsAdapter();
29+
30+
@override
31+
BandEntry get entry => kBangleJs;
32+
33+
/// Empty on purpose. No card may ever key off this adapter's id.
34+
@override
35+
Map<InputSignal, Duration> get signals => const {};
36+
37+
@override
38+
Stream<BandEvent> run(BandLink link) async* {
39+
// No handshake, no write, no line reassembly. Every notification chunk is
40+
// re-emitted verbatim as raw bytes — decoding a text REPL's output as if
41+
// it were a stable record format would be exactly the guess this project
42+
// never takes.
43+
await for (final (_, value) in link.notify(kNordicUartTxChar)) {
44+
yield SampleBatch(
45+
const [],
46+
raw: [Uint8List.fromList(value)],
47+
ephemeral: false,
48+
);
49+
}
50+
// No OffloadCheckpoint: nothing here is ever told to forget anything.
51+
}
52+
}
53+
54+
/// The single instance. Const, so it costs nothing to reference.
55+
const BangleJsAdapter kBangleJsAdapter = BangleJsAdapter();

lib/ble/adapters/casio.dart

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// A Casio G-Shock / current-generation Casio smartwatch, speaking the 2C/2D
2+
// "all-features" GATT scheme, as a [BandAdapter].
3+
//
4+
// NOTHING HERE HAS MET HARDWARE. Nobody on this project owns a Casio watch,
5+
// so every byte below is verified by the compiler and by the fixtures in
6+
// `test/adapters/casio_adapter_test.dart`, never by a real device. It ships
7+
// EXPERIMENTAL (ASSUMPTIONS R6): `signals` is `const {}` and `kAdapterSignals`
8+
// carries no entry for it beyond the empty map, exactly like `kOura` and
9+
// `kBleHrs`.
10+
//
11+
// THE WIRE SHAPE IS THE SIMPLEST ONE THIS SEAM HAS SEEN. One request
12+
// characteristic, one response characteristic, no envelope, no CRC, no
13+
// sequence counter, and no stored history to drain — closer to a plain notify
14+
// sensor than to anything WHOOP-shaped. The host writes a one-byte feature tag
15+
// and the watch answers on notify with `[featureTag, ...payload]`; there is
16+
// nothing to ACK and nothing to trim.
17+
//
18+
// WHAT THIS SESSION DOES AND DOES NOT DO. It writes a small, fixed set of
19+
// harmless, read-only feature tags — version info, app info, watch name,
20+
// module id, BLE features — and banks every reply verbatim. It never writes
21+
// the clock, an alarm, a reminder or any other setting: those are
22+
// control-plane writes with device-specific side effects, and a pairs-only
23+
// adapter has no business making them. No field of any reply is decoded; a
24+
// module-id BYTE COUNT is the one thing surfaced as a [BandNote], because it
25+
// is pure device metadata (not a measurement) and unambiguous without a
26+
// decoder.
27+
28+
import 'dart:async';
29+
import 'dart:typed_data';
30+
31+
import '_registry.dart';
32+
import 'adapter.dart';
33+
import 'signals.dart';
34+
35+
/// One Casio session. Const: it holds no state of its own — everything a
36+
/// session needs lives inside [run].
37+
class CasioAdapter extends BandAdapter {
38+
/// How long to wait for a reply to one request before moving on. A watch
39+
/// that never answers a given tag costs one skipped probe, not a stalled
40+
/// session — there is no retry and no escalation here.
41+
final Duration replyTimeout;
42+
43+
const CasioAdapter({this.replyTimeout = const Duration(seconds: 3)});
44+
45+
@override
46+
BandEntry get entry => kCasio;
47+
48+
/// NOTHING, and that is the honest answer today rather than a placeholder.
49+
/// This adapter decodes no field of any reply — every response is banked
50+
/// raw and undecoded until someone has actually held one of these watches.
51+
@override
52+
Map<InputSignal, Duration> get signals => const {};
53+
54+
/// The harmless, read-only feature-request tags this session proves the
55+
/// link with. Every one is a plain info read: none sets the clock, an
56+
/// alarm, a reminder or any other watch state.
57+
static const List<int> kProbeTags = <int>[
58+
0x10, // BLE features
59+
0x20, // version info
60+
0x22, // app info
61+
0x23, // watch name
62+
0x26, // module id
63+
];
64+
65+
/// The one probe tag whose reply length is worth naming — a byte count is
66+
/// pure device metadata, never a measurement.
67+
static const int _kModuleIdTag = 0x26;
68+
69+
@override
70+
Stream<BandEvent> run(BandLink link) async* {
71+
final inbox = _Inbox();
72+
final sub = link.notify(kCasioAllFeaturesChar).listen(
73+
(rec) => inbox.add(Uint8List.fromList(rec.$2)),
74+
onDone: inbox.close,
75+
onError: (Object _) => inbox.close(),
76+
);
77+
final raw = <Uint8List>[];
78+
try {
79+
for (final tag in kProbeTags) {
80+
if (!await link.write(kCasioReadRequestChar, <int>[tag])) {
81+
link.log(
82+
'casio: request 0x${tag.toRadixString(16)} refused; skipping.');
83+
continue;
84+
}
85+
// A frame whose first byte does not echo this tag is not this
86+
// request's answer — an unsolicited setting notification, or a stale
87+
// reply that missed a PREVIOUS tag's timeout window and landed here
88+
// instead. Banked anyway (every frame this wire sends is still
89+
// undecoded data worth keeping) but never claimed as tag's reply, so
90+
// it can never taint the module-id length note below.
91+
final deadline = DateTime.now().add(replyTimeout);
92+
Uint8List? resp;
93+
while (true) {
94+
final left = deadline.difference(DateTime.now());
95+
if (left <= Duration.zero) break;
96+
final frame = await inbox.next(left);
97+
if (frame == null) break;
98+
if (frame.isNotEmpty && frame[0] == tag) {
99+
resp = frame;
100+
break;
101+
}
102+
if (frame.isNotEmpty) raw.add(frame);
103+
}
104+
if (resp == null) {
105+
link.log(
106+
'casio: no reply to request 0x${tag.toRadixString(16)}.');
107+
continue;
108+
}
109+
raw.add(resp);
110+
if (tag == _kModuleIdTag && resp.length > 1) {
111+
yield BandNote('casio_module_id_len', resp.length - 1);
112+
}
113+
}
114+
} finally {
115+
await sub.cancel();
116+
}
117+
// No samples, ever — nothing is decoded. The frames are handed over so a
118+
// future decoder, written when someone owns one of these watches, has
119+
// something to run over.
120+
if (raw.isNotEmpty) yield SampleBatch(const [], raw: raw);
121+
// No OffloadCheckpoint: there is no stored history on this wire to trim.
122+
}
123+
}
124+
125+
/// The single instance. Const, so it costs nothing to reference.
126+
const CasioAdapter kCasioAdapter = CasioAdapter();
127+
128+
/// Frames off the notify characteristic, buffered so a reply landing before
129+
/// anyone is waiting is not dropped. Same minimal shape as `oura.dart`'s own
130+
/// inbox — this wire has no auth handshake to search past, so there is no
131+
/// `firstWhere`, only "the next frame, or nothing".
132+
class _Inbox {
133+
final List<Uint8List> _buf = [];
134+
Completer<Uint8List?>? _waiter;
135+
bool _closed = false;
136+
137+
void add(Uint8List v) {
138+
final w = _waiter;
139+
if (w != null && !w.isCompleted) {
140+
_waiter = null;
141+
w.complete(v);
142+
return;
143+
}
144+
_buf.add(v);
145+
}
146+
147+
void close() {
148+
_closed = true;
149+
final w = _waiter;
150+
_waiter = null;
151+
if (w != null && !w.isCompleted) w.complete(null);
152+
}
153+
154+
Future<Uint8List?> next(Duration timeout) {
155+
if (_buf.isNotEmpty) return Future.value(_buf.removeAt(0));
156+
if (_closed) return Future.value(null);
157+
final w = Completer<Uint8List?>();
158+
_waiter = w;
159+
return w.future.timeout(timeout, onTimeout: () {
160+
if (identical(_waiter, w)) _waiter = null;
161+
return null;
162+
});
163+
}
164+
}

0 commit comments

Comments
 (0)