Skip to content
Open
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
11 changes: 8 additions & 3 deletions PulseLoop/RingProtocol/ColmiEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
}

/// User profile. Sensible neutral defaults; PulseLoop can wire real profile values later.
func userPreferences(metric: Bool = true, gender: UInt8 = 0x02, age: UInt8 = 25, heightCm: UInt8 = 175, weightKg: UInt8 = 70) -> [UInt8] {

Check warning on line 38 in PulseLoop/RingProtocol/ColmiEncoder.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line should be 140 characters or less; currently it has 142 characters (line_length)
[
ColmiCommandID.preferences,
ColmiCommandID.prefWrite,
Expand Down Expand Up @@ -96,9 +96,14 @@
[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),
Expand Down
3 changes: 1 addition & 2 deletions PulseLoop/RingProtocol/ColmiSyncEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,12 @@
case .activity:
daysAgo = 0; stage = .heartRate; requestHeartRate()
case .heartRate:
if isVitalsBackfill { stage = .spo2; requestSpo2() }

Check warning on line 195 in PulseLoop/RingProtocol/ColmiSyncEngine.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Else and catch should be on the same line, one space after the previous declaration (statement_position)
else { stage = .stress; requestStress() }
case .stress:
stage = .spo2; requestSpo2()
case .spo2:
if isVitalsBackfill { finishSync() }

Check warning on line 200 in PulseLoop/RingProtocol/ColmiSyncEngine.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Else and catch should be on the same line, one space after the previous declaration (statement_position)
else { stage = .sleep; requestSleep() }
case .sleep:
daysAgo = 0; stage = .hrv; requestHRV()
Expand All @@ -224,8 +224,7 @@

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() {
Expand Down Expand Up @@ -297,16 +296,16 @@

switch stage {
case .activity:
if daysAgo < 7 { daysAgo += 1; requestActivity() }

Check warning on line 299 in PulseLoop/RingProtocol/ColmiSyncEngine.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Else and catch should be on the same line, one space after the previous declaration (statement_position)
else { daysAgo = 0; stage = .heartRate; requestHeartRate() }
case .heartRate:
if isVitalsBackfill { stage = .spo2; requestSpo2() }

Check warning on line 302 in PulseLoop/RingProtocol/ColmiSyncEngine.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Else and catch should be on the same line, one space after the previous declaration (statement_position)
else if daysAgo < 7 { daysAgo += 1; requestHeartRate() }

Check warning on line 303 in PulseLoop/RingProtocol/ColmiSyncEngine.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Else and catch should be on the same line, one space after the previous declaration (statement_position)
else { stage = .stress; requestStress() }
case .stress:
stage = .spo2; requestSpo2()
case .hrv:
if daysAgo < 6 { daysAgo += 1; requestHRV() }

Check warning on line 308 in PulseLoop/RingProtocol/ColmiSyncEngine.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Else and catch should be on the same line, one space after the previous declaration (statement_position)
else { stage = .temperature; requestTemperature() }
default:
break
Expand Down
45 changes: 45 additions & 0 deletions PulseLoopTests/ColmiEncoderTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading