Skip to content
Open
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
28 changes: 28 additions & 0 deletions lib/ble/adapters/_registry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ const String kOuraCommandChar = '98ed0002-a541-11e4-b6a0-0002a5d5c51b';
/// event share this one characteristic — there is no separate data pipe.
const String kOuraNotifyChar = '98ed0003-a541-11e4-b6a0-0002a5d5c51b';

/// The Lefun-protocol family's GATT service. One shared write/notify pair
/// covers battery, firmware info and every historical report code alike —
/// there is no separate command channel the way Oura's ring has.
const String kLefunService = '000018d0-0000-1000-8000-00805f9b34fb';
const String kLefunWriteChar = '00002d01-0000-1000-8000-00805f9b34fb';
const String kLefunNotifyChar = '00002d00-0000-1000-8000-00805f9b34fb';

/// What a stored timestamp actually IS for a given band.
///
/// The distinction is load-bearing and it is not cosmetic. A WHOOP record
Expand Down Expand Up @@ -438,12 +445,32 @@ const BandEntry kOura = BandEntry.notify(
timeAnchor: TimeAnchor.arrival,
);

/// A Lefun-protocol OEM ring or band — the shared reference design behind a
/// long list of storefront names, not one branded product. Plain, unencrypted
/// GATT: no key, no nonce, no challenge/response anywhere in the envelope.
///
/// EXPERIMENTAL, and it stays that way: nobody on this project owns one, so
/// not a byte of this path has met hardware (ASSUMPTIONS R6). Only the
/// envelope and its checksum, plus the battery report, are decoded with any
/// confidence — steps, sleep and PPG all ride the same envelope under their
/// own report codes and have no decoder here, so `signals` is `const {}` and
/// nothing this device writes becomes a metric.
const BandEntry kLefun = BandEntry.notify(
id: 'lefun',
label: 'Smart ring/band (Lefun protocol)',
service: kLefunService,
characteristics: <String>[kLefunWriteChar, kLefunNotifyChar],
// No clock in the envelope this file decodes. See [TimeAnchor].
timeAnchor: TimeAnchor.arrival,
);

/// Every band this build can see. Order is match order during discovery.
const List<BandEntry> kBandRegistry = <BandEntry>[
kWhoopGen4,
kWhoopGen5,
kBleHrs,
kOura,
kLefun,
];

/// The bands the OFFLOAD ENGINE can drive, and the bands iOS provisions
Expand Down Expand Up @@ -500,6 +527,7 @@ const Map<String, Map<InputSignal, Duration>> kAdapterSignals =
InputSignal.rrIntervals: Duration(seconds: 1),
},
'oura': <InputSignal, Duration>{},
'lefun': <InputSignal, Duration>{},
};

/// The signals one adapter declares, or empty for an id this build has no
Expand Down
80 changes: 80 additions & 0 deletions lib/ble/adapters/lefun.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// A Lefun-protocol OEM ring/band as a [BandAdapter]: no auth, one bounded
// battery poll, bank every frame the radio hands back.
//
// NOTHING HERE HAS MET HARDWARE (ASSUMPTIONS R6). The envelope and the
// battery report are the only things `protocol`'s `lefun.dart` decodes with
// any confidence — steps, sleep and PPG all ride the same envelope under
// their own report codes and have no decoder there, so `signals` is
// `const {}` and every frame is archived verbatim rather than turned into a
// value.
//
// A BOUNDED POLL, NOT A LIVE SESSION. Unlike a chest strap this device has no
// per-second stream to hold a link open for — the one thing worth asking for
// is battery, and everything else worth banking is whatever the ring pushes
// on its own in the few seconds after that ask. So `run()` writes one
// request, waits out [replyTimeout], and ends — the host tears the link down
// once the stream closes, the same way `oura.dart`'s one-shot drain does.
//
// ponytail: no GET_ACTIVITY_DATA / GET_SLEEP_DATA / GET_PPG_DATA request
// builders here, even though their request bodies (a single day-index or
// PPG-type byte) are simple. Their RESPONSE layouts are not decoded by this
// file, so requesting them would only bank more undecoded bytes for a
// question nobody has asked yet — add them when someone verifies a response
// shape against real hardware.

import 'dart:async';
import 'dart:typed_data';

import 'package:openstrap_protocol/openstrap_protocol.dart';

import '_registry.dart';
import 'adapter.dart';
import 'signals.dart';

/// The adapter. Not const: [replyTimeout] is overridable so a test does not
/// have to sit through the real wait.
class LefunAdapter extends BandAdapter {
/// How long to hold the link open after the battery request, banking
/// whatever arrives on the notify characteristic in that window.
final Duration replyTimeout;

LefunAdapter({this.replyTimeout = const Duration(seconds: 5)});

@override
BandEntry get entry => kLefun;

/// NOTHING. See the module doc for why battery — the one report this file
/// decodes — is not a signal either: it is device state, not a
/// physiological reading, and it reaches the host as a [BandNote].
@override
Map<InputSignal, Duration> get signals => const {};

@override
Stream<BandEvent> run(BandLink link) async* {
final raw = <Uint8List>[];
int? batteryPct;
final sub = link.notify(kLefunNotifyChar).listen((rec) {
final f = parseLefunFrame(rec.$2);
if (f == null) return;
// Every frame archived verbatim, decoded or not (owner rulings R1-R3):
// steps, sleep and PPG all ride this same envelope with no decoder
// here, and the bytes are banked now so one written later can be run
// over them.
raw.add(Uint8List.fromList(rec.$2));
if (f.report == kLefunReportBattery) {
batteryPct = decodeLefunBattery(f.params) ?? batteryPct;
}
});
try {
if (!await link.write(kLefunWriteChar, buildLefunFrame(kLefunReportBattery))) {
link.log('lefun: battery request refused.');
} else {
await Future<void>.delayed(replyTimeout);
}
if (batteryPct case final pct?) yield BandNote('battery', pct);
if (raw.isNotEmpty) yield SampleBatch(const [], raw: raw);
} finally {
await sub.cancel();
}
}
}
7 changes: 5 additions & 2 deletions lib/ble/ble_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1858,8 +1858,11 @@ class WriteChain {
/// safe-trim invariant depends on (commit-then-ACK, flash trim), and making it wait
/// behind a chest strap would turn a sensor's 12 s connect timeout into a delayed
/// band sync — which is exactly the regression this milestone must not ship. With
/// today's two pairable sensor kinds (kBleHrs, kOura) the cap is never reached, so
/// this is a guard for the N-device future, not a change in behaviour.
/// today's three pairable sensor kinds (kBleHrs, kOura, kLefun) the cap CAN be
/// reached — a chest strap armed for a workout holds a slot for its whole
/// duration, and a background wake syncing a ring and a Lefun device together
/// wants both of what is left — but the mechanism is a queue, not a refusal:
/// the caller that arrives once both slots are taken waits, it does not fail.
const int kMaxConcurrentSecondaryLinks = 2;

int _secondaryLinksInUse = 0;
Expand Down
30 changes: 27 additions & 3 deletions lib/ble/hrs_link.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import 'ble_state.dart'
withScanLock,
acquireSecondaryLinkSlot,
releaseSecondaryLinkSlot;
import 'lefun_link.dart' show LefunLink;
import 'oura_link.dart' show OuraLink;

export 'adapters/host.dart' show HrsReading;
Expand Down Expand Up @@ -500,6 +501,9 @@ class HrsLink {
/// [tier] defaults to the strap's, and a band whose measurement quality
/// differs must say so rather than inherit it — the tier is what decides
/// precedence between two sources, so a wrong one is a silent wrong number.
/// Pass null explicitly for a device that supplies no signal at all (the
/// same refusal `pairOuraRing` makes by never setting the column) — there is
/// no quality to rank on a device `BandAdapter.signals` declares nothing for.
///
/// Nothing is written unless the peripheral passed the characteristic check:
/// a row pointing at a device that cannot answer is a sensor that appears
Expand All @@ -508,7 +512,7 @@ class HrsLink {
BandEntry entry,
BluetoothDevice device, {
String? label,
String tier = 'beatToBeat',
String? tier = 'beatToBeat',
}) async {
try {
await device.connect(timeout: _connectTimeout);
Expand Down Expand Up @@ -582,9 +586,29 @@ class HrsLink {
await OuraLink.forgetRing(id);
return;
}
if (row?['adapter_id'] == kLefun.id) {
// No secret to drop — the envelope this device speaks has no key
// exchange — so this is a plain stop-and-delete, same shape as Oura's
// forget minus the keychain half. GATED ON THE LIVE SESSION ACTUALLY
// BEING THIS ROW: `LefunLink` is a singleton over potentially several
// paired rows, so stopping it unconditionally would drop a DIFFERENT
// Lefun device's in-flight sync if one happened to be live when this
// one was forgotten.
if (LefunLink.instance.currentDeviceId == id) {
await LefunLink.instance.stop();
}
await LocalDb.deleteDevice(id);
return;
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
}
// Before the row goes, not after: a live session would keep writing rows
// under an id nothing can explain any more.
await instance.disarm();
// under an id nothing can explain any more. GATED ON THE ROW BEING THE
// ARMED HRS SENSOR, not called unconditionally — this fallback used to run
// for ANY adapter without its own branch above, which would tear down a
// live chest-strap session while forgetting an unrelated device (e.g. a
// Lefun ring paired alongside one).
if (row?['adapter_id'] == kBleHrsAdapter.id) {
await instance.disarm();
}
await LocalDb.deleteDevice(id);
}

Expand Down
Loading
Loading