Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/openstrap_protocol.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
95 changes: 95 additions & 0 deletions lib/src/zetime.dart
Original file line number Diff line number Diff line change
@@ -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<int> 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<int> zetimeRequestFrame(int cmd) => <int>[
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<int> 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;
}
54 changes: 54 additions & 0 deletions test/zetime_test.dart
Original file line number Diff line number Diff line change
@@ -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),
<int>[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);
});
}
Loading