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
9 changes: 7 additions & 2 deletions PulseLoop/RingProtocol/ColmiDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,13 @@ struct ColmiDecoder {
comps.year = 2000 + hexLit(v[1])
comps.month = hexLit(v[2])
comps.day = hexLit(v[3])
comps.hour = min(23, max(0, Int(v[4]) / 4)) // nth quarter of day, clamped to a valid hour
comps.minute = 0
// v[4] is the nth quarter-hour of the day (0…95): hour = q/4, minute = (q%4)*15. Dropping the
// within-hour offset collapsed all four quarters of an hour onto HH:00, so — since the day
// total is the sum of buckets keyed by their start epoch — three of every four quarters
// overwrote each other and daily steps/distance were undercounted by up to ~75%.
let quarter = min(95, max(0, Int(v[4])))
comps.hour = quarter / 4
comps.minute = (quarter % 4) * 15
comps.second = 0
guard let ts = calendar.date(from: comps) else { return [] }
// Reject implausible dates (misframed packet / bad BCD): must be within the last ~8 days.
Expand Down
28 changes: 28 additions & 0 deletions PulseLoopTests/ColmiDecoderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,34 @@ final class ColmiDecoderTests: XCTestCase {
XCTAssertEqual(percent, 84)
}

// MARK: Activity history buckets

/// Two quarter-hour buckets in the same hour must decode to distinct timestamps (:30 vs :45).
/// Regression: the decoder used to hard-code minute 0, collapsing all four quarters of an hour
/// onto HH:00; since day totals sum buckets keyed by start epoch, three of four quarters
/// overwrote each other and steps/distance were undercounted by up to ~75%.
func testActivityHistoryPreservesQuarterHourOffset() {
// Reference clock so the decoded timestamp lands inside the [now-8d, now+1h] window.
let now = calendar.date(from: DateComponents(year: 2026, month: 7, day: 13, hour: 12))!
// BCD-literal date bytes for 2026-07-13, then the quarter-of-day index and steps=u16(v9,v10).
func frame(quarter: UInt8, steps: UInt8) -> Data {
ColmiPacket.frame([ColmiCommandID.syncActivity, 0x26, 0x07, 0x13, quarter,
0, 0, 0, 0, steps, 0, 0, 0])
}
guard case let .activityBucket(ts6, s6, _) =
decoder.decodeHistory(frame(quarter: 6, steps: 100), day: now, calendar: calendar, now: now).first,
case let .activityBucket(ts7, s7, _) =
decoder.decodeHistory(frame(quarter: 7, steps: 200), day: now, calendar: calendar, now: now).first else {
return XCTFail("expected activityBucket events")
}
XCTAssertEqual(s6, 100)
XCTAssertEqual(s7, 200)
XCTAssertEqual(calendar.component(.hour, from: ts6), 1)
XCTAssertEqual(calendar.component(.minute, from: ts6), 30) // quarter 6 → 01:30
XCTAssertEqual(calendar.component(.minute, from: ts7), 45) // quarter 7 → 01:45
XCTAssertNotEqual(ts6, ts7, "same-hour quarter buckets must not share a timestamp/epoch")
}

func testRealtimeHeartRate() {
let frame = ColmiPacket.frame([ColmiCommandID.realtimeHeartRate, 72])
let events = decoder.decodeNormal(frame)
Expand Down
Loading