From 7759fc7959331c4056a81e03acbb578f68454ff5 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:31:20 +0530 Subject: [PATCH 1/3] zetime: command envelope + battery decode pairs-only groundwork for mykronoz zetime. frame codec (preamble/cmd/action/ len/end) plus a battery-level reply decode. no history/step/sleep/hr commands touched. --- lib/openstrap_protocol.dart | 1 + lib/src/zetime.dart | 90 +++++++++++++++++++++++++++++++++++++ test/zetime_test.dart | 49 ++++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 lib/src/zetime.dart create mode 100644 test/zetime_test.dart diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index 10abe3c..b8ac583 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'; // Source 1 — record decoders. export 'src/records.dart' diff --git a/lib/src/zetime.dart b/lib/src/zetime.dart new file mode 100644 index 0000000..28444c4 --- /dev/null +++ b/lib/src/zetime.dart @@ -0,0 +1,90 @@ +// 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 plus the trailing `[0x8f]` (i.e. `payload.length +/// + 1`). +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 or carries no level byte. +int? zetimeBatteryLevel(ZeTimeFrame f) => + f.cmd == kZeTimeCmdBattery && f.payload.isNotEmpty ? f.payload[0] : null; diff --git a/test/zetime_test.dart b/test/zetime_test.dart new file mode 100644 index 0000000..2ce473b --- /dev/null +++ b/test/zetime_test.dart @@ -0,0 +1,49 @@ +// 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('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); + }); +} From d8a1685343e134ef7f6bd6edf7c41aeaf792ccfa Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:53:19 +0530 Subject: [PATCH 2/3] zetime: refuse a battery byte above 100 a misidentified or malformed reply could otherwise report an impossible percentage instead of nothing. --- lib/src/zetime.dart | 11 ++++++++--- test/zetime_test.dart | 5 +++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/src/zetime.dart b/lib/src/zetime.dart index 28444c4..612b886 100644 --- a/lib/src/zetime.dart +++ b/lib/src/zetime.dart @@ -85,6 +85,11 @@ ZeTimeFrame? parseZeTimeFrame(List value) { } /// Battery level, 0-100, from a battery-command reply. Null when [f] is not a -/// battery reply or carries no level byte. -int? zetimeBatteryLevel(ZeTimeFrame f) => - f.cmd == kZeTimeCmdBattery && f.payload.isNotEmpty ? f.payload[0] : null; +/// 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 index 2ce473b..97abda2 100644 --- a/test/zetime_test.dart +++ b/test/zetime_test.dart @@ -21,6 +21,11 @@ void main() { 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); From db18584c1ae599f42db6ac2c32ccc7656e587aa3 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:46:30 +0530 Subject: [PATCH 3/3] =?UTF-8?q?zetime:=20fix=20frame=20doc=20=E2=80=94=20d?= =?UTF-8?q?eclared=20length=20is=20payload.length,=20not=20+1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/src/zetime.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/zetime.dart b/lib/src/zetime.dart index 612b886..af01efa 100644 --- a/lib/src/zetime.dart +++ b/lib/src/zetime.dart @@ -32,8 +32,8 @@ const int kZeTimeCmdBattery = 0x08; /// One decoded frame off the wire: /// `[0x6f][cmd][action][lenLo][lenHi]…payload…[0x8f]`, where the declared -/// length counts the payload plus the trailing `[0x8f]` (i.e. `payload.length -/// + 1`). +/// 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;