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
36 changes: 30 additions & 6 deletions lib/src/oura.dart
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,9 @@ OuraEvent? parseOuraEvent(OuraFrame f) {
}

// ── event tags this file has something to say about ────────────────────────
/// Wall-clock the ring recorded when the host last set its RTC. The ONLY event
/// that pairs a Unix second with an envelope decisecond, which makes it the one
/// honest anchor between the two clocks.
/// Wall-clock the ring recorded when the host last set its RTC. It pairs a Unix
/// second with an envelope decisecond, so it anchors one clock to the other
/// (as does [kOuraEvtRtcBeacon]).
const int kOuraEvtTimeSync = 0x42;

/// An array of skin-temperature probes.
Expand All @@ -101,6 +101,10 @@ const int kOuraEvtTemp = 0x46;
/// A single skin-temperature reading.
const int kOuraEvtTempPeriod = 0x69;

/// The RTC beacon, a wall-clock anchor the ring emits on its own. See
/// [decodeRtcBeacon].
const int kOuraEvtRtcBeacon = 0x85;

/// Sleep-stage hypnogram carriers: `information`, `details`, and `data`
/// (numbered 14-byte pages, 52 epochs each). Same codes on all three.
const int kOuraEvtSleepPhaseInformation = 0x4b;
Expand Down Expand Up @@ -135,6 +139,25 @@ int? decodeTimeSync(OuraEvent e) {
return (v >= 1700000000 && v <= 4100000000) ? v : null;
}

/// Unix seconds from an `rtc_beacon` (`0x85`) body, or null when [e] is not
/// one or the value is not a date.
///
/// Layout: `u32` LE Unix seconds at offset 0, then reserved bytes and a `u16`
/// at offset 8 whose meaning is unknown. Bodies under 10 bytes are refused.
/// Same date window as [decodeTimeSync], so an unset RTC never becomes an
/// anchor.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
///
/// Like [decodeTimeSync], this layout has no real captured [kOuraEvtRtcBeacon]
/// frame in this repo behind it, so check a real one against it before
/// trusting it for anything beyond the date window.
int? decodeRtcBeacon(OuraEvent e) {
if (e.tag != kOuraEvtRtcBeacon || e.body.length < 10) return null;
final v = e.body.buffer
.asByteData(e.body.offsetInBytes)
.getUint32(0, Endian.little);
return (v >= 1700000000 && v <= 4100000000) ? v : null;
}

/// Skin temperature in degrees Celsius, one entry per probe.
///
/// The wire carries signed 16-bit little-endian CENTI-degrees. Anything outside
Expand Down Expand Up @@ -392,9 +415,10 @@ List<int> ouraCmdSetNotifyFlags(int flags) => <int>[0x1c, 0x01, flags & 0xff];
/// Set the ring's real-time clock: u64 LE Unix seconds, then a timezone in
/// half-hour steps.
///
/// This is what later produces a [kOuraEvtTimeSync] event, and that event is the
/// only measured bridge between the ring's decisecond counter and a date — so
/// this write is not housekeeping, it is what makes the timestamps meaningful.
/// This is what later produces a [kOuraEvtTimeSync] event, one of the two
/// measured bridges (with [kOuraEvtRtcBeacon]) between the ring's decisecond
/// counter and a date — so this write is not housekeeping, it is what makes the
/// timestamps meaningful.
List<int> ouraCmdSyncTime(int unixSeconds, {int tzHalfHours = 0}) {
final b = Uint8List(9);
final d = b.buffer.asByteData();
Expand Down
18 changes: 18 additions & 0 deletions test/oura_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,24 @@ void main() {
parseOuraFrame(_hex('4208') + _hex('01000000') + _hex('00000000'))!)!;
expect(decodeTimeSync(unset), isNull);
});
test('an rtc beacon is a little-endian unix second', () {
final ok = parseOuraEvent(parseOuraFrame(
_hex('850e') + _hex('01000000') + _hex('4fd2376a00000000e803'))!)!;
expect(decodeRtcBeacon(ok), 1782043215);
// A 9-byte body, one short of the full layout, is not a beacon.
final short = parseOuraEvent(parseOuraFrame(
_hex('850d') + _hex('01000000') + _hex('4fd2376a00000000e8'))!)!;
expect(short.body.length, 9);
expect(decodeRtcBeacon(short), isNull);
// A different tag is not a beacon either.
final other = parseOuraEvent(parseOuraFrame(
_hex('420e') + _hex('01000000') + _hex('4fd2376a00000000e803'))!)!;
expect(decodeRtcBeacon(other), isNull);
// An unset RTC is not an anchor.
final unset = parseOuraEvent(parseOuraFrame(
_hex('850e') + _hex('01000000') + _hex('0100000000000000e803'))!)!;
expect(decodeRtcBeacon(unset), isNull);
});
});

group('outbound frames', () {
Expand Down
Loading