diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index 403066e..45ce5ec 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/zetime.dart'; export 'src/dafit.dart'; export 'src/polar_pmd.dart'; export 'src/lefun.dart'; diff --git a/lib/src/zetime.dart b/lib/src/zetime.dart new file mode 100644 index 0000000..af01efa --- /dev/null +++ b/lib/src/zetime.dart @@ -0,0 +1,95 @@ +// MyKronoz ZeTime's command envelope — plain functions, no crypto, no key +// exchange. One write characteristic elicits a reply on a separate notify +// characteristic; there is no bonding requirement and no encrypted payload. +// +// NOTHING HERE HAS MET HARDWARE. Nobody on this project owns one and +// `flutter_blue_plus` has no simulator path, so this is verified by the wire +// layout and by the compiler. +// +// FRAMING AND TWO DEVICE FACTS ONLY. This file parses the envelope and the +// one reply worth surfacing as a vendor fact today: battery level. Step +// count, sleep and heart-rate history are commands this protocol supports and +// this file deliberately does not touch, request, or decode: they are the +// health signals this band has not been hardware-verified for, and a decoder +// for them is not something to guess at from a spec alone. + +/// First byte of every frame. +const int kZeTimePreamble = 0x6f; + +/// Last byte of every frame. +const int kZeTimeEnd = 0x8f; + +/// Action byte (third position): a host-to-device question. +const int kZeTimeActionRequest = 0x70; + +/// The one byte written to the ack characteristic after every write to the +/// command characteristic — a fixed acknowledgement token, not a per-command +/// value. +const int kZeTimeAckToken = 0x03; + +/// Command byte for a battery-level request/reply. +const int kZeTimeCmdBattery = 0x08; + +/// One decoded frame off the wire: +/// `[0x6f][cmd][action][lenLo][lenHi]…payload…[0x8f]`, where the declared +/// length counts the payload bytes only — it equals `payload.length` exactly +/// and does not count the trailing `[0x8f]`. +class ZeTimeFrame { + final int cmd; + final int action; + final List payload; + const ZeTimeFrame({ + required this.cmd, + required this.action, + required this.payload, + }); +} + +/// Build a request frame for [cmd]: preamble, command, REQUEST action, and +/// the one-byte declared length the device's own request frames always send +/// (`[0x01, 0x00]`) even though the byte it counts is unused — transcribed +/// rather than guessed at, because a zero-length declaration is a different, +/// untested frame shape. +List zetimeRequestFrame(int cmd) => [ + kZeTimePreamble, + cmd, + kZeTimeActionRequest, + 0x01, + 0x00, + 0x00, + kZeTimeEnd, + ]; + +/// Parse one notify-characteristic value as a complete frame. Null when it is +/// too short, does not start with the preamble, declares a zero-length +/// payload (the device never sends one), its declared length does not match +/// the bytes actually delivered, or it does not end on [kZeTimeEnd] — a +/// malformed or still-fragmented notification is dropped rather than guessed +/// at. A payload that splits across two BLE notifications (the device's own +/// behaviour once the payload exceeds 14 bytes) is not reassembled here: every +/// command this file builds declares a payload well under that, so nothing in +/// this file ever exercises that path. +ZeTimeFrame? parseZeTimeFrame(List value) { + if (value.length < 7) return null; + if (value[0] != kZeTimePreamble) return null; + final payloadSize = value[3] | (value[4] << 8); + if (payloadSize == 0) return null; + final msgLength = payloadSize + 6; + if (msgLength != value.length) return null; + if (value[msgLength - 1] != kZeTimeEnd) return null; + return ZeTimeFrame( + cmd: value[1], + action: value[2], + payload: value.sublist(5, msgLength - 1), + ); +} + +/// Battery level, 0-100, from a battery-command reply. Null when [f] is not a +/// battery reply, carries no level byte, or the byte is outside 0-100 — a +/// single-byte percentage cannot legitimately read above 100, and a value +/// that does is a misidentified reply, not a real reading. +int? zetimeBatteryLevel(ZeTimeFrame f) { + if (f.cmd != kZeTimeCmdBattery || f.payload.isEmpty) return null; + final level = f.payload[0]; + return level <= 100 ? level : null; +} diff --git a/test/zetime_test.dart b/test/zetime_test.dart new file mode 100644 index 0000000..97abda2 --- /dev/null +++ b/test/zetime_test.dart @@ -0,0 +1,54 @@ +// MyKronoz ZeTime's command envelope — pinned against the documented wire +// layout, not against a captured device: nobody on this project owns one. + +import 'package:test/test.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +void main() { + test('battery request frame is the fixed 7-byte shape', () { + expect( + zetimeRequestFrame(kZeTimeCmdBattery), + [0x6f, 0x08, 0x70, 0x01, 0x00, 0x00, 0x8f], + ); + }); + + test('parses a battery reply and reads its level', () { + // [preamble][cmd][action][lenLo][lenHi][level][end] — one payload byte, + // so the declared length is 1 + 6 = 7 total. + final f = parseZeTimeFrame([0x6f, 0x08, 0x01, 0x01, 0x00, 63, 0x8f])!; + expect(f.cmd, kZeTimeCmdBattery); + expect(f.payload, [63]); + expect(zetimeBatteryLevel(f), 63); + }); + + test('refuses a level byte above 100 — not a real percentage', () { + final f = parseZeTimeFrame([0x6f, 0x08, 0x01, 0x01, 0x00, 200, 0x8f])!; + expect(zetimeBatteryLevel(f), isNull); + }); + + test('a non-battery frame has no battery level', () { + final f = parseZeTimeFrame([0x6f, 0x02, 0x01, 0x01, 0x00, 9, 0x8f])!; + expect(zetimeBatteryLevel(f), isNull); + }); + + test('refuses a short buffer', () { + expect(parseZeTimeFrame([0x6f, 0x08, 0x01, 0x01, 0x00]), isNull); + }); + + test('refuses a missing preamble', () { + expect(parseZeTimeFrame([0x00, 0x08, 0x01, 0x01, 0x00, 63, 0x8f]), isNull); + }); + + test('refuses a zero-length declaration', () { + expect(parseZeTimeFrame([0x6f, 0x08, 0x01, 0x00, 0x00, 0x8f]), isNull); + }); + + test('refuses a declared length that does not match the buffer', () { + // Declares a 2-byte payload but only 1 arrived. + expect(parseZeTimeFrame([0x6f, 0x08, 0x01, 0x02, 0x00, 63, 0x8f]), isNull); + }); + + test('refuses a missing end marker', () { + expect(parseZeTimeFrame([0x6f, 0x08, 0x01, 0x01, 0x00, 63, 0x00]), isNull); + }); +}