diff --git a/PulseLoop/RingProtocol/ColmiEncoder.swift b/PulseLoop/RingProtocol/ColmiEncoder.swift index 4bbec61..faf1969 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 1d44ae4..d6badc9 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 0000000..971efff --- /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)) + } +}