From efb109725f8147a233eb7c4b7d97e0f7164824ac Mon Sep 17 00:00:00 2001 From: Saksham Bhutani Date: Sat, 1 Aug 2026 23:43:25 -0400 Subject: [PATCH] Send Colmi HR-history timestamp in the ring's local-as-UTC frame --- PulseLoop/RingProtocol/ColmiEncoder.swift | 11 +++-- PulseLoop/RingProtocol/ColmiSyncEngine.swift | 3 +- PulseLoopTests/ColmiEncoderTests.swift | 45 ++++++++++++++++++++ 3 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 PulseLoopTests/ColmiEncoderTests.swift diff --git a/PulseLoop/RingProtocol/ColmiEncoder.swift b/PulseLoop/RingProtocol/ColmiEncoder.swift index 4bbec61e..faf19691 100644 --- a/PulseLoop/RingProtocol/ColmiEncoder.swift +++ b/PulseLoop/RingProtocol/ColmiEncoder.swift @@ -96,9 +96,14 @@ struct ColmiEncoder { [ColmiCommandID.syncActivity, UInt8(clamping: daysAgo), 0x0f, 0x00, 0x5f, 0x01] } - /// HR history from a unix timestamp (seconds), little-endian in bytes 1-4. - func syncHeartRate(fromUnix seconds: Int) -> [UInt8] { - let ts = UInt32(truncatingIfNeeded: seconds) + /// HR history request (0x15), timestamp little-endian in bytes 1-4. The ring's RTC runs on local + /// wall time (see `setDateTime`), so its firmware keys history days by "local-time-as-UTC" + /// epochs. Send local midnight *plus* the UTC offset — matching GadgetBridge + /// (`getTimeInMillis() + ZONE_OFFSET + DST_OFFSET`) and colmi_r02_client (midnight with + /// tzinfo=UTC). A true UTC epoch misses the day slot and the ring replies "empty" for every day. + func syncHeartRate(dayStart: Date, timeZone: TimeZone = .current) -> [UInt8] { + let offset = timeZone.secondsFromGMT(for: dayStart) + let ts = UInt32(truncatingIfNeeded: Int(dayStart.timeIntervalSince1970) + offset) return [ ColmiCommandID.syncHeartRate, UInt8(ts & 0xff), diff --git a/PulseLoop/RingProtocol/ColmiSyncEngine.swift b/PulseLoop/RingProtocol/ColmiSyncEngine.swift index 1d44ae4c..d6badc96 100644 --- a/PulseLoop/RingProtocol/ColmiSyncEngine.swift +++ b/PulseLoop/RingProtocol/ColmiSyncEngine.swift @@ -224,8 +224,7 @@ final class ColmiSyncEngine: RingSyncEngine { private func requestHeartRate() { syncDay = dayStart(daysAgo) - let unix = Int(syncDay.timeIntervalSince1970) - writer?.enqueue(Data(encoder.syncHeartRate(fromUnix: unix))) + writer?.enqueue(Data(encoder.syncHeartRate(dayStart: syncDay))) } private func requestStress() { diff --git a/PulseLoopTests/ColmiEncoderTests.swift b/PulseLoopTests/ColmiEncoderTests.swift new file mode 100644 index 00000000..971efff4 --- /dev/null +++ b/PulseLoopTests/ColmiEncoderTests.swift @@ -0,0 +1,45 @@ +import XCTest +@testable import PulseLoop + +final class ColmiEncoderTests: XCTestCase { + + private let encoder = ColmiEncoder() + + /// Little-endian u32 from the request's timestamp bytes 1-4. + private func timestamp(_ bytes: [UInt8]) -> UInt32 { + UInt32(bytes[1]) | UInt32(bytes[2]) << 8 | UInt32(bytes[3]) << 16 | UInt32(bytes[4]) << 24 + } + + private func midnight(in zone: TimeZone, year: Int, month: Int, day: Int) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = zone + return calendar.date(from: DateComponents(year: year, month: month, day: day))! + } + + // The ring's RTC runs on local wall time, so the 0x15 HR-history request must carry local + // midnight *as if it were UTC* (epoch + UTC offset), matching GadgetBridge / colmi_r02_client. + // A true UTC epoch makes the ring reply "empty" for every day (the R03 field bug). + + func testSyncHeartRatePositiveOffset() { + let zone = TimeZone(identifier: "Asia/Singapore")! // UTC+8, no DST + let day = midnight(in: zone, year: 2026, month: 7, day: 24) + let bytes = encoder.syncHeartRate(dayStart: day, timeZone: zone) + XCTAssertEqual(bytes[0], ColmiCommandID.syncHeartRate) + XCTAssertEqual(timestamp(bytes), UInt32(day.timeIntervalSince1970) + 8 * 3600) + } + + func testSyncHeartRateNegativeOffsetDSTAware() { + let zone = TimeZone(identifier: "America/Los_Angeles")! // UTC-7 in July (PDT) + let day = midnight(in: zone, year: 2026, month: 7, day: 24) + let bytes = encoder.syncHeartRate(dayStart: day, timeZone: zone) + XCTAssertEqual(zone.secondsFromGMT(for: day), -7 * 3600) + XCTAssertEqual(timestamp(bytes), UInt32(Int(day.timeIntervalSince1970) - 7 * 3600)) + } + + func testSyncHeartRateGMTIsIdentity() { + let zone = TimeZone(identifier: "GMT")! + let day = midnight(in: zone, year: 2026, month: 7, day: 24) + let bytes = encoder.syncHeartRate(dayStart: day, timeZone: zone) + XCTAssertEqual(timestamp(bytes), UInt32(day.timeIntervalSince1970)) + } +}