From 217bb476b044eb92357f2d54e016b61dbb7efa47 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:31:27 +0530 Subject: [PATCH 1/3] dafit/moyoung: frame + ack + handshake, pairs-only no signal decode yet, just the framing to hold a session open and bank raw bytes. --- lib/openstrap_protocol.dart | 1 + lib/src/dafit.dart | 166 ++++++++++++++++++++++++++++++++++++ test/dafit_test.dart | 82 ++++++++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 lib/src/dafit.dart create mode 100644 test/dafit_test.dart diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index 10abe3c..dede97e 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'; // Source 1 — record decoders. export 'src/records.dart' diff --git a/lib/src/dafit.dart b/lib/src/dafit.dart new file mode 100644 index 0000000..325c9a2 --- /dev/null +++ b/lib/src/dafit.dart @@ -0,0 +1,166 @@ +// 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), or a payload length longer than the bytes actually +/// delivered. +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) 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". +bool isDafitAckFrame(List value) => + value.length == 8 && value[0] == kDafitAckHeader; + +/// 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. +int packDafitDateTime(DateTime t) { + return t.second | + ((t.year - 2000) << 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..f9678c1 --- /dev/null +++ b/test/dafit_test.dart @@ -0,0 +1,82 @@ +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('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('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('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); + }); + }); +} From 3f99abc4312748d7189cbfa734288bb3f06145dc Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:59:50 +0530 Subject: [PATCH 2/3] dafit/moyoung: reject malformed frames and unrepresentable years length-mismatched frames were accepted and echoed back into acks; a year outside 2000-2063 silently truncated in the clock write. sourcery --- lib/src/dafit.dart | 24 ++++++++++++++++++++---- test/dafit_test.dart | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/lib/src/dafit.dart b/lib/src/dafit.dart index 325c9a2..7968fd4 100644 --- a/lib/src/dafit.dart +++ b/lib/src/dafit.dart @@ -73,13 +73,19 @@ class DafitFrame { /// 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), or a payload length longer than the bytes actually -/// delivered. +/// 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) return null; + if (value.length - 8 < payloadLen || outerLen != 5 + payloadLen) { + return null; + } return DafitFrame( value[3], value[5], @@ -132,9 +138,19 @@ bool isDafitAckFrame(List value) => /// 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 | - ((t.year - 2000) << 26) | + (yearOffset << 26) | (t.month << 22) | (t.day << 17) | (t.hour << 12) | diff --git a/test/dafit_test.dart b/test/dafit_test.dart index f9678c1..597e872 100644 --- a/test/dafit_test.dart +++ b/test/dafit_test.dart @@ -25,6 +25,18 @@ void main() { 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); @@ -65,6 +77,15 @@ void main() { 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)); From 06cb5f2a2a6c9b9b60e439a423af447837f7267a Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:01:36 +0530 Subject: [PATCH 3/3] dafit/moyoung: isDafitAckFrame checks the whole fixed shape not just the header byte. sourcery --- lib/src/dafit.dart | 12 +++++++++++- test/dafit_test.dart | 8 ++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/src/dafit.dart b/lib/src/dafit.dart index 7968fd4..193b199 100644 --- a/lib/src/dafit.dart +++ b/lib/src/dafit.dart @@ -132,8 +132,18 @@ Uint8List buildDafitAck(DafitFrame acked) { /// 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.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 diff --git a/test/dafit_test.dart b/test/dafit_test.dart index 597e872..ada4fef 100644 --- a/test/dafit_test.dart +++ b/test/dafit_test.dart @@ -46,6 +46,14 @@ void main() { 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]);