diff --git a/lib/src/oura.dart b/lib/src/oura.dart index 518ca64..9fe5668 100644 --- a/lib/src/oura.dart +++ b/lib/src/oura.dart @@ -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. @@ -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; @@ -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. +/// +/// 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 @@ -392,9 +415,10 @@ List ouraCmdSetNotifyFlags(int flags) => [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 ouraCmdSyncTime(int unixSeconds, {int tzHalfHours = 0}) { final b = Uint8List(9); final d = b.buffer.asByteData(); diff --git a/test/oura_test.dart b/test/oura_test.dart index 8d5ce74..5d18b58 100644 --- a/test/oura_test.dart +++ b/test/oura_test.dart @@ -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', () {