diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index 2b8a502..d25a4a8 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/wearfit.dart'; export 'src/o2ring.dart'; export 'src/ringconn.dart'; export 'src/zetime.dart'; diff --git a/lib/src/wearfit.dart b/lib/src/wearfit.dart new file mode 100644 index 0000000..3f5bad3 --- /dev/null +++ b/lib/src/wearfit.dart @@ -0,0 +1,108 @@ +// WearFit wire format — the framing spoken by the Howear family of bands +// (models sold as HK8 Ultra, HK8 Pro Max and similar), paired through the +// "WearFit", "WearFit 2.0" or "WearFit Pro" companion app. 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, read the band's own battery report, +// and bank whatever else it sends. 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). A second, separate service +// UUID is advertised for discovery only and carries no characteristics of +// its own. +// +// FRAME, app<->band, one frame per notification/write, little header: +// [0] header (0xAB) +// [1] reserved — always 0x00 +// [2] length: byte count from [3] to the end of the frame, i.e. +// 2 + payload.length +// [3] 0xFF — fixed marker, not part of the opcode space +// [4] command opcode +// [5..] payload +// There is no CRC and no encryption anywhere in this envelope: a command is +// accepted purely on its opcode, and the length field is the only structure +// this file can check a frame against. + +import 'dart:typed_data'; + +const int kWearFitHeader = 0xAB; + +/// Battery status, opcode [kWearFitOpBattery]. +const int kWearFitOpBattery = 0x91; + +/// One parsed frame. Null for anything too short or missing the fixed +/// marker — see [parseWearFitFrame]. +class WearFitFrame { + final int opcode; + final Uint8List payload; + const WearFitFrame(this.opcode, this.payload); +} + +/// Parse one notification. Null when it cannot be a frame at all: too short, +/// the wrong header, a missing `0xFF` marker, a declared length under 2 (no +/// room for the opcode byte), or a payload longer than the bytes actually +/// delivered. +WearFitFrame? parseWearFitFrame(List value) { + if (value.length < 5 || value[0] != kWearFitHeader || value[3] != 0xFF) { + return null; + } + final len = value[2]; + if (len < 2) return null; + final payloadLen = len - 2; + if (value.length - 5 < payloadLen) return null; + return WearFitFrame( + value[4], + Uint8List.fromList(value.sublist(5, 5 + payloadLen)), + ); +} + +/// Build one outbound frame. +/// +/// [payload] must leave room for the length byte at [2] (`2 + payload.length` +/// fits in one byte): 253 bytes or fewer. +Uint8List buildWearFitFrame(int opcode, [List payload = const []]) { + if (payload.length > 253) { + throw ArgumentError.value(payload.length, 'payload.length', + 'must be 253 or fewer — the frame length byte cannot hold more'); + } + final len = 2 + payload.length; + final out = Uint8List(5 + payload.length); + out[0] = kWearFitHeader; + out[1] = 0x00; + out[2] = len & 0xff; + out[3] = 0xFF; + out[4] = opcode & 0xff; + out.setRange(5, 5 + payload.length, payload); + return out; +} + +/// Ask the band for its current battery status. Documented request shape, +/// not a guess: `[0x80, 0x01]` is the request payload behind the battery +/// reply this file parses below, and asking is a read, not a write of any +/// band state. +Uint8List wearFitCmdGetBattery() => buildWearFitFrame(kWearFitOpBattery, const [0x80, 0x01]); + +/// The band's own battery report. +class WearFitBattery { + /// 0 = not charging, 1 = charging, 2 = fully charged. + final int chargeState; + + /// 0-100. + final int percent; + + const WearFitBattery(this.chargeState, this.percent); +} + +/// Parse [f] as a battery reply, or null when it is not one. This is device +/// housekeeping, not a physiological reading — nothing about this decode is +/// gated by the family's EXPERIMENTAL status. +WearFitBattery? parseWearFitBattery(WearFitFrame f) { + if (f.opcode != kWearFitOpBattery || f.payload.length < 3) return null; + final state = f.payload[1]; + final pct = f.payload[2]; + if (state > 2 || pct > 100) return null; + return WearFitBattery(state, pct); +} diff --git a/test/wearfit_test.dart b/test/wearfit_test.dart new file mode 100644 index 0000000..87db653 --- /dev/null +++ b/test/wearfit_test.dart @@ -0,0 +1,116 @@ +// The WearFit wire format, against real captured bytes: a documented +// find-watch request, a battery request/reply pair, and a device-info reply, +// each shown byte-for-byte in the family's own protocol notes. Nothing here +// was copied from anyone's decoder — the frame envelope is small enough that +// an independent read of the bytes is the whole of the proof. + +import 'package:test/test.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +void main() { + group('WearFit framing', () { + test('parses a real captured battery reply (not charging, 80%)', () { + // AB 00 05 FF 91 80 00 50 + final f = parseWearFitFrame([0xab, 0x00, 0x05, 0xff, 0x91, 0x80, 0x00, 0x50]); + expect(f, isNotNull); + expect(f!.opcode, 0x91); + expect(f.payload, [0x80, 0x00, 0x50]); + }); + + test('parses a real captured battery reply (charging, 80%)', () { + // AB 00 05 FF 91 80 01 50 + final f = parseWearFitFrame([0xab, 0x00, 0x05, 0xff, 0x91, 0x80, 0x01, 0x50]); + expect(f!.payload, [0x80, 0x01, 0x50]); + }); + + test('parses a real captured device-info reply', () { + // AB 00 11 FF 92 C0 08 04 38 00 00 00 00 00 00 28 00 60 00 6B + final f = parseWearFitFrame([ + 0xab, 0x00, 0x11, 0xff, 0x92, // + 0xc0, 0x08, 0x04, 0x38, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x28, 0x00, 0x60, 0x00, 0x6b, + ]); + expect(f, isNotNull); + expect(f!.opcode, 0x92); + expect(f.payload.length, 15); // len 0x11 - 2 + }); + + test('rejects a frame missing the 0xFF marker', () { + expect(parseWearFitFrame([0xab, 0x00, 0x05, 0x00, 0x91, 0x80, 0x00, 0x50]), isNull); + }); + + test('rejects a truncated frame', () { + // declares len 5 (3 payload bytes) but only one is present + expect(parseWearFitFrame([0xab, 0x00, 0x05, 0xff, 0x91, 0x80]), isNull); + }); + + test('rejects a length too short to carry an opcode-following byte', () { + expect(parseWearFitFrame([0xab, 0x00, 0x01, 0xff, 0x91]), isNull); + }); + + test('rejects a frame with the wrong header byte', () { + expect(parseWearFitFrame([0xac, 0x00, 0x05, 0xff, 0x91, 0x80, 0x00, 0x50]), isNull); + }); + + test('buildWearFitFrame round-trips through parseWearFitFrame', () { + final built = buildWearFitFrame(0x71, const [0x80]); + // AB 00 03 FF 71 80 — the real captured "find watch" request. + expect(built, [0xab, 0x00, 0x03, 0xff, 0x71, 0x80]); + final parsed = parseWearFitFrame(built); + expect(parsed!.opcode, 0x71); + expect(parsed.payload, [0x80]); + }); + + test('buildWearFitFrame with no payload', () { + expect(buildWearFitFrame(0x20), [0xab, 0x00, 0x02, 0xff, 0x20]); + }); + + test('wearFitCmdGetBattery matches the real captured request', () { + // AB 00 04 FF 91 80 01 + expect(wearFitCmdGetBattery(), [0xab, 0x00, 0x04, 0xff, 0x91, 0x80, 0x01]); + }); + + test('buildWearFitFrame accepts the largest payload the length byte can hold', () { + final built = buildWearFitFrame(0x20, List.filled(253, 0x01)); + expect(built[2], 0xff); + expect(built.length, 258); + }); + + test('buildWearFitFrame rejects a payload the length byte cannot hold', () { + expect(() => buildWearFitFrame(0x20, List.filled(254, 0x01)), + throwsArgumentError); + }); + }); + + group('WearFit battery', () { + test('decodes not-charging at 80%', () { + final f = parseWearFitFrame([0xab, 0x00, 0x05, 0xff, 0x91, 0x80, 0x00, 0x50])!; + final b = parseWearFitBattery(f); + expect(b, isNotNull); + expect(b!.chargeState, 0); + expect(b.percent, 80); + }); + + test('decodes fully-charged at 100%', () { + // AB 00 05 FF 91 80 02 64 + final f = parseWearFitFrame([0xab, 0x00, 0x05, 0xff, 0x91, 0x80, 0x02, 0x64])!; + final b = parseWearFitBattery(f); + expect(b!.chargeState, 2); + expect(b.percent, 100); + }); + + test('refuses a non-battery opcode', () { + final f = parseWearFitFrame([0xab, 0x00, 0x03, 0xff, 0x71, 0x80])!; + expect(parseWearFitBattery(f), isNull); + }); + + test('refuses a battery frame too short to carry a percent', () { + final f = parseWearFitFrame([0xab, 0x00, 0x03, 0xff, 0x91, 0x80])!; + expect(parseWearFitBattery(f), isNull); + }); + + test('refuses an implausible percent', () { + final f = parseWearFitFrame([0xab, 0x00, 0x05, 0xff, 0x91, 0x80, 0x00, 0xc8])!; + expect(parseWearFitBattery(f), isNull); + }); + }); +}