diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index ee87030..403066e 100644 --- a/lib/openstrap_protocol.dart +++ b/lib/openstrap_protocol.dart @@ -18,6 +18,7 @@ export 'src/band.dart' show DeviceType, GattProfile, BandProfile; // sharing one barrel must not share a bare verb. export 'src/oura.dart'; export 'src/hrs.dart'; +export 'src/dafit.dart'; export 'src/polar_pmd.dart'; export 'src/lefun.dart'; diff --git a/lib/src/dafit.dart b/lib/src/dafit.dart new file mode 100644 index 0000000..193b199 --- /dev/null +++ b/lib/src/dafit.dart @@ -0,0 +1,192 @@ +// DaFit / MOYOUNG-V2 wire format — the framing spoken by a large cluster of +// unbranded Chinese OEM watches, paired through the "DaFit" or "MOYOUNG" +// companion app and sold under dozens of storefront names. Bytes only: no +// BLE, no Flutter, no database. +// +// NOTHING HERE HAS MET HARDWARE. Ships EXPERIMENTAL (ASSUMPTIONS R6): this +// file decodes NO physiological signal from this family's frames, only the +// framing needed to hold a session open and bank what the band sends. A +// data-bearing reply that goes unacknowledged is documented to leave the +// band stalled and draining its battery fast, so the minimal ack this file +// builds is protocol plumbing, not a derived metric — nothing here turns +// bytes into a heart rate, a step count or a sleep stage. +// +// TRANSPORT is an otherwise-generic Nordic UART Service link (one write +// characteristic, one notify characteristic, no CRC, no encryption). +// +// DATA FRAME (header 0xCD), app<->band, big-endian length fields: +// [0] header (0xCD) +// [1..2] outer length: byte count from [3] to the end of payload, +// i.e. 5 + payload.length +// [3] command group +// [4] protocol version/delimiter — always 0x01 +// [5] command +// [6..7] payload length +// [8..] payload +// +// ACK FRAME (header 0xDC), a fixed 8 bytes, sent to keep a data-bearing +// exchange moving: +// [0] 0xDC [1..2] 0x00 0x05 (fixed) [3] command group [4] 0x01 +// [5..6] (outer length of the frame being acked) + 3 +// [7] 0x01 +// This shape does not nest inside the data-frame layout above — it is +// parsed and built separately, never through [parseDafitFrame]. + +import 'dart:typed_data'; + +const int kDafitDataHeader = 0xCD; +const int kDafitAckHeader = 0xDC; + +const int kDafitGroupGeneral = 0x12; +const int kDafitGroupRequestData = 0x1a; +const int kDafitGroupBandInfo = 0x20; + +const int kDafitCmdSetDateTime = 0x01; +const int kDafitCmdSetLanguage = 0x15; +const int kDafitCmdInit1 = 0x0a; +const int kDafitCmdInit2 = 0x0c; +const int kDafitCmdInit3 = 0xff; + +/// group [kDafitGroupRequestData]. +const int kDafitCmdGetHwInfo = 0x10; + +/// group [kDafitGroupBandInfo]. +const int kDafitCmdGetBandInfo = 0x02; + +const int kDafitValueOn = 0x01; +const int kDafitLangEnglish = 0x01; + +/// One parsed data frame ([kDafitDataHeader]). Null for anything else, +/// including this family's own ACK frames — see [parseDafitFrame]. +class DafitFrame { + final int group; + final int command; + final Uint8List payload; + + /// The frame's own declared outer length (bytes [1..2]) — kept verbatim, + /// not recomputed from [payload], so [buildDafitAck] echoes exactly what + /// the band itself declared rather than this file's own arithmetic. + final int outerLen; + + const DafitFrame(this.group, this.command, this.payload, this.outerLen); +} + +/// Parse one notification as a data frame. Null when it cannot be one — +/// too short, the wrong header (an ACK frame reads `0xDC` here and is +/// correctly rejected), a payload length longer than the bytes actually +/// delivered, or an outer length that disagrees with the payload length — +/// the two are supposed to describe the same frame (`outerLen == 5 + +/// payloadLen`), and a frame where they disagree is malformed, not merely +/// unfamiliar. Accepting it anyway would hand [buildDafitAck] a length to +/// echo back that this frame never actually had. +DafitFrame? parseDafitFrame(List value) { + if (value.length < 8 || value[0] != kDafitDataHeader) return null; + final outerLen = (value[1] << 8) | value[2]; + final payloadLen = (value[6] << 8) | value[7]; + if (value.length - 8 < payloadLen || outerLen != 5 + payloadLen) { + return null; + } + return DafitFrame( + value[3], + value[5], + Uint8List.fromList(value.sublist(8, 8 + payloadLen)), + outerLen, + ); +} + +/// Build one outbound data frame. +Uint8List buildDafitFrame( + int group, + int command, [ + List payload = const [], +]) { + final out = Uint8List(8 + payload.length); + final outerLen = 5 + payload.length; + out[0] = kDafitDataHeader; + out[1] = (outerLen >> 8) & 0xff; + out[2] = outerLen & 0xff; + out[3] = group & 0xff; + out[4] = 0x01; + out[5] = command & 0xff; + out[6] = (payload.length >> 8) & 0xff; + out[7] = payload.length & 0xff; + out.setRange(8, 8 + payload.length, payload); + return out; +} + +/// Build the fixed 8-byte ACK for a received data frame, so a data-bearing +/// exchange keeps moving instead of stalling. Takes the frame being +/// acknowledged directly — its [DafitFrame.outerLen] is the only input, so +/// there is no separate length argument to drift out of sync with it. +Uint8List buildDafitAck(DafitFrame acked) { + final ackSize = acked.outerLen + 3; + return Uint8List.fromList([ + kDafitAckHeader, 0x00, 0x05, // + acked.group, 0x01, + (ackSize >> 8) & 0xff, ackSize & 0xff, + 0x01, + ]); +} + +/// True for the fixed 8-byte ACK shape ([kDafitAckHeader]) — the direction +/// this family also uses band-to-app, interspersed with data frames during +/// the same exchange. Not decoded further: nothing downstream needs more +/// than "this notification was an ack, not data". +/// +/// Checks every fixed byte the shape actually has ([1], [2], [4] and [7] are +/// all constant — see [buildDafitAck]), not just the header: an 8-byte +/// notification that happens to start with 0xDC but disagrees with the rest +/// of the shape is not an ack, it is something this file does not recognise. +bool isDafitAckFrame(List value) => + value.length == 8 && + value[0] == kDafitAckHeader && + value[1] == 0x00 && + value[2] == 0x05 && + value[4] == 0x01 && + value[7] == 0x01; + +/// Pack a local date/time into this family's bit-packed SET_DATE_TIME field: +/// seconds, then (year-2000), month, day, hour, minute each shifted into +/// their own bit range of one big-endian u32. +/// +/// `year - 2000` has to fit the field's own six bits (0-63, i.e. 2000-2063). +/// Outside that range the later shift would silently drop the high bits and +/// write a DIFFERENT year to the band's clock — a wrong date it would then +/// act on with total confidence — so this throws instead. +int packDafitDateTime(DateTime t) { + final yearOffset = t.year - 2000; + if (yearOffset < 0 || yearOffset > 63) { + throw ArgumentError.value(t.year, 'year', + 'this family\'s clock field only represents 2000-2063'); + } + return t.second | + (yearOffset << 26) | + (t.month << 22) | + (t.day << 17) | + (t.hour << 12) | + (t.minute << 6); +} + +/// Handshake frames, in the order this family expects them. Sending them +/// unconditionally is documented behaviour, not a guess: skipping this +/// sequence is reported to leave later fetches refused and the band's +/// battery draining fast, and each step is a control write, not a request +/// for any physiological data. A caller pauses between writes; timing is a +/// session concern and stays out of this file. +List dafitInitSequence(DateTime now) => [ + buildDafitFrame(kDafitGroupGeneral, kDafitCmdInit1, const [0x02]), + buildDafitFrame( + kDafitGroupGeneral, + kDafitCmdSetDateTime, + (ByteData(4)..setUint32(0, packDafitDateTime(now) & 0xffffffff)) + .buffer + .asUint8List(), + ), + buildDafitFrame(kDafitGroupRequestData, kDafitCmdInit1), + buildDafitFrame(kDafitGroupRequestData, kDafitCmdInit2), + buildDafitFrame( + kDafitGroupGeneral, kDafitCmdSetLanguage, const [kDafitLangEnglish]), + buildDafitFrame(kDafitGroupGeneral, kDafitCmdInit3, const [kDafitValueOn]), + buildDafitFrame(kDafitGroupRequestData, kDafitCmdGetHwInfo), + buildDafitFrame(kDafitGroupBandInfo, kDafitCmdGetBandInfo), + ]; diff --git a/test/dafit_test.dart b/test/dafit_test.dart new file mode 100644 index 0000000..ada4fef --- /dev/null +++ b/test/dafit_test.dart @@ -0,0 +1,111 @@ +import 'package:openstrap_protocol/openstrap_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('DaFit / MOYOUNG-V2 framing', () { + test('parses a real captured SET_DATE_TIME data frame', () { + // 0xCD 0x00 0x09 0x12 0x01 0x01 0x00 0x04 0xA5 0x83 0x73 0xDB + final f = parseDafitFrame( + [0xcd, 0x00, 0x09, 0x12, 0x01, 0x01, 0x00, 0x04, 0xa5, 0x83, 0x73, 0xdb]); + expect(f, isNotNull); + expect(f!.group, 0x12); + expect(f.command, 0x01); + expect(f.outerLen, 0x09); + expect(f.payload, [0xa5, 0x83, 0x73, 0xdb]); + }); + + test('rejects an ACK-headed frame as a data frame', () { + // dc 00 05 1a 01 00 0c 01 + expect(parseDafitFrame([0xdc, 0x00, 0x05, 0x1a, 0x01, 0x00, 0x0c, 0x01]), + isNull); + }); + + test('rejects a truncated payload', () { + expect(parseDafitFrame([0xcd, 0x00, 0x09, 0x12, 0x01, 0x01, 0x00, 0x04, 0xa5]), + isNull); + }); + + test('rejects an outer length that disagrees with the payload length', + () { + // Same bytes as the real captured frame above, but the outer length + // (byte 2) claims 8 instead of the correct 9 — a malformed frame, not + // a shorter one, since the payload bytes and their own length field + // still say 4. + expect( + parseDafitFrame( + [0xcd, 0x00, 0x08, 0x12, 0x01, 0x01, 0x00, 0x04, 0xa5, 0x83, 0x73, 0xdb]), + isNull); + }); + + test('isDafitAckFrame identifies only the fixed 8-byte ack shape', () { + expect(isDafitAckFrame([0xdc, 0x00, 0x05, 0x1a, 0x01, 0x00, 0x0c, 0x01]), + isTrue); + expect( + isDafitAckFrame( + [0xcd, 0x00, 0x09, 0x12, 0x01, 0x01, 0x00, 0x04, 0xa5, 0x83, 0x73, 0xdb]), + isFalse); + }); + + test('isDafitAckFrame rejects an 8-byte 0xDC packet with the wrong fixed ' + 'bytes', () { + // Right header and length, but the trailing fixed byte is not the 0x01 + // this shape always carries. + expect(isDafitAckFrame([0xdc, 0x00, 0x05, 0x1a, 0x01, 0x00, 0x0c, 0x00]), + isFalse); + }); + + test('buildDafitFrame round-trips through parseDafitFrame', () { + final built = buildDafitFrame(0x12, 0x0a, const [0x02]); + expect(built, [0xcd, 0x00, 0x06, 0x12, 0x01, 0x0a, 0x00, 0x01, 0x02]); + final parsed = parseDafitFrame(built); + expect(parsed!.group, 0x12); + expect(parsed.command, 0x0a); + expect(parsed.payload, [0x02]); + }); + + test('buildDafitFrame with no payload', () { + final built = buildDafitFrame(0x1a, 0x0a); + expect(built, [0xcd, 0x00, 0x05, 0x1a, 0x01, 0x0a, 0x00, 0x00]); + }); + + test('buildDafitAck echoes the acked frame\'s own outer length', () { + final acked = parseDafitFrame([0xcd, 0x00, 0x05, 0x1a, 0x01, 0x0a, 0x00, 0x00])!; + final ack = buildDafitAck(acked); + expect(ack, [0xdc, 0x00, 0x05, 0x1a, 0x01, 0x00, 0x08, 0x01]); + }); + + test('packDafitDateTime packs fields into the documented bit ranges', () { + final t = DateTime(2024, 3, 5, 14, 22, 37); + final v = packDafitDateTime(t); + expect(v & 0x3f, 37); // seconds, bits 0-5 + expect((v >> 6) & 0x3f, 22); // minute + expect((v >> 12) & 0x1f, 14); // hour + expect((v >> 17) & 0x1f, 5); // day + expect((v >> 22) & 0xf, 3); // month + expect((v >> 26) & 0x3f, 24); // year - 2000 + }); + + test('packDafitDateTime refuses a year outside 2000-2063', () { + expect(() => packDafitDateTime(DateTime(1999, 1, 1)), + throwsArgumentError); + expect(() => packDafitDateTime(DateTime(2064, 1, 1)), + throwsArgumentError); + // The boundary itself is fine. + expect(() => packDafitDateTime(DateTime(2063, 1, 1)), returnsNormally); + }); + + test('dafitInitSequence is eight well-formed, parseable frames', () { + final seq = dafitInitSequence(DateTime(2024, 3, 5, 14, 22, 37)); + expect(seq, hasLength(8)); + for (final frame in seq) { + expect(frame[0], kDafitDataHeader); + expect(parseDafitFrame(frame), isNotNull); + } + // The clock write really carries the packed value from above. + final clockFrame = parseDafitFrame(seq[1])!; + expect(clockFrame.group, kDafitGroupGeneral); + expect(clockFrame.command, kDafitCmdSetDateTime); + expect(clockFrame.payload.length, 4); + }); + }); +}