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/wearfit.dart';
export 'src/o2ring.dart';
export 'src/ringconn.dart';
export 'src/zetime.dart';
Expand Down
108 changes: 108 additions & 0 deletions lib/src/wearfit.dart
Original file line number Diff line number Diff line change
@@ -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<int> 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<int> payload = const <int>[]]) {
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);
}
116 changes: 116 additions & 0 deletions test/wearfit_test.dart
Original file line number Diff line number Diff line change
@@ -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<int>.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<int>.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);
});
});
}
Loading