소스 검색

Updates Garmin watch state logic

Refactors Garmin watch state to include separate timestamps for loop determination and glucose reading.
This improves the watchface's ability to accurately determine glucose freshness and loop staleness.

The Garmin manager is modified so that IOB and Glucose update triggers wait 20s for determination trigger arrival before sending watch updates.
Robert 3 달 전
부모
커밋
786aef899f
2개의 변경된 파일91개의 추가작업 그리고 17개의 파일을 삭제
  1. 10 1
      Trio/Sources/Models/GarminWatchState.swift
  2. 81 16
      Trio/Sources/Services/WatchManager/GarminManager.swift

+ 10 - 1
Trio/Sources/Models/GarminWatchState.swift

@@ -13,9 +13,14 @@ import SwiftUI
 /// Uses the SwissAlpine xDrip+ compatible data format.
 /// Sent as an array where the first entry contains all extended data fields.
 struct GarminWatchState: Hashable, Equatable, Sendable, Encodable {
-    /// Timestamp of the glucose reading in milliseconds since Unix epoch
+    /// Timestamp of the enacted loop determination in milliseconds since Unix epoch
+    /// Shows when the loop actually executed, used to indicate loop staleness
     var date: UInt64?
 
+    /// Timestamp of the glucose reading in milliseconds since Unix epoch
+    /// Used by watchface to determine glucose freshness for coloring logic
+    var glucoseDate: UInt64?
+
     /// Sensor glucose value in raw mg/dL (no unit conversion applied)
     var sgv: Int16?
 
@@ -61,6 +66,7 @@ struct GarminWatchState: Hashable, Equatable, Sendable, Encodable {
 
     static func == (lhs: GarminWatchState, rhs: GarminWatchState) -> Bool {
         lhs.date == rhs.date &&
+            lhs.glucoseDate == rhs.glucoseDate &&
             lhs.sgv == rhs.sgv &&
             lhs.delta == rhs.delta &&
             lhs.direction == rhs.direction &&
@@ -78,6 +84,7 @@ struct GarminWatchState: Hashable, Equatable, Sendable, Encodable {
 
     func hash(into hasher: inout Hasher) {
         hasher.combine(date)
+        hasher.combine(glucoseDate)
         hasher.combine(sgv)
         hasher.combine(delta)
         hasher.combine(direction)
@@ -95,6 +102,7 @@ struct GarminWatchState: Hashable, Equatable, Sendable, Encodable {
 
     enum CodingKeys: String, CodingKey {
         case date
+        case glucoseDate
         case sgv
         case delta
         case direction
@@ -115,6 +123,7 @@ struct GarminWatchState: Hashable, Equatable, Sendable, Encodable {
     func encode(to encoder: Encoder) throws {
         var container = encoder.container(keyedBy: CodingKeys.self)
         try container.encodeIfPresent(date, forKey: .date)
+        try container.encodeIfPresent(glucoseDate, forKey: .glucoseDate)
         try container.encodeIfPresent(sgv, forKey: .sgv)
         try container.encodeIfPresent(delta, forKey: .delta)
         try container.encodeIfPresent(direction, forKey: .direction)

+ 81 - 16
Trio/Sources/Services/WatchManager/GarminManager.swift

@@ -180,11 +180,11 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
             }
             .store(in: &subscriptions)
 
-        // IOB updates - needed for manual boluses which update IOB independently of loop
+        // IOB updates - also wait for determination like glucose does
         iobService.iobPublisher
             .receive(on: DispatchQueue.global(qos: .background))
             .sink { [weak self] _ in
-                self?.triggerWatchStateUpdate(triggeredBy: "IOB")
+                self?.handleIOBUpdate()
             }
             .store(in: &subscriptions)
 
@@ -289,6 +289,41 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
         debugGarmin("Garmin: Glucose received - waiting \(Int(glucoseFallbackDelay))s for determination")
     }
 
+    /// Handles IOB updates with delayed fallback
+    /// Also waits up to 20 seconds for determination to arrive, restarting the shared timer
+    /// This prevents IOB changes from triggering premature watch updates before determination arrives
+    private func handleIOBUpdate() {
+        guard !devices.isEmpty else { return }
+
+        // Cancel any existing fallback timer (restart the 20s window)
+        pendingGlucoseFallback?.cancel()
+
+        // Create new fallback task
+        let fallback = DispatchWorkItem { [weak self] in
+            guard let self = self else { return }
+
+            Task {
+                do {
+                    self
+                        .debugGarmin(
+                            "Garmin: IOB fallback timer fired (no determination in \(Int(self.glucoseFallbackDelay))s)"
+                        )
+
+                    let watchState = try await self.setupGarminWatchState(triggeredBy: "IOB (fallback)")
+                    let watchStateData = try JSONEncoder().encode(watchState)
+                    self.watchStateSubject.send(watchStateData)
+                } catch {
+                    debug(.watchManager, "Garmin: Error in IOB fallback: \(error)")
+                }
+            }
+        }
+
+        pendingGlucoseFallback = fallback
+        timerQueue.asyncAfter(deadline: .now() + glucoseFallbackDelay, execute: fallback)
+
+        debugGarmin("Garmin: IOB received - waiting \(Int(glucoseFallbackDelay))s for determination")
+    }
+
     /// Triggers watch state preparation and sends to debounce subject
     /// If triggered by Determination, cancels pending glucose fallback timer
     private func triggerWatchStateUpdate(triggeredBy trigger: String) {
@@ -364,6 +399,27 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
         }
     }
 
+    /// Fetches all determinations from the last 30 minutes (no fetch limit).
+    /// Returns them sorted newest first, allowing us to find both enacted and suggested determinations.
+    /// - Returns: An array of `NSManagedObjectID`s for all determinations in the 30-minute window.
+    private func fetchDeterminations30Min() async throws -> [NSManagedObjectID] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: OrefDetermination.self,
+            onContext: backgroundContext,
+            predicate: NSPredicate.predicateFor30MinAgoForDetermination,
+            key: "deliverAt",
+            ascending: false,
+            fetchLimit: 0 // No limit - get all determinations in 30min window
+        )
+
+        return try await backgroundContext.perform {
+            guard let fetchedResults = results as? [OrefDetermination] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
+            return fetchedResults.map(\.objectID)
+        }
+    }
+
     // MARK: - Watch State Setup
 
     /// Builds an array of GarminWatchState objects containing current glucose, trend, loop data, and historical readings.
@@ -384,16 +440,16 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
         let glucoseLimit = needsHistoricalGlucoseData ? 24 : 2
         let glucoseIds = try await fetchGlucose(limit: glucoseLimit)
 
-        let determinationIds = try await determinationStorage.fetchLastDeterminationObjectID(
-            predicate: NSPredicate.enactedDetermination
-        )
+        // Fetch all determinations from last 30 minutes (no limit)
+        // This ensures we get both enacted and suggested determinations
+        let allDeterminationIds = try await fetchDeterminations30Min()
 
         let tempBasalIds = try await fetchTempBasals()
 
         let glucoseObjects: [GlucoseStored] = try await CoreDataStack.shared
             .getNSManagedObject(with: glucoseIds, context: backgroundContext)
-        let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared
-            .getNSManagedObject(with: determinationIds, context: backgroundContext)
+        let allDeterminationObjects: [OrefDetermination] = try await CoreDataStack.shared
+            .getNSManagedObject(with: allDeterminationIds, context: backgroundContext)
         let tempBasalObjects: [PumpEventStored] = try await CoreDataStack.shared
             .getNSManagedObject(with: tempBasalIds, context: backgroundContext)
 
@@ -405,15 +461,22 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
             // IOB with 1 decimal precision
             let iobValue = self.formatIOB(self.iobService.currentIOB ?? Decimal(0))
 
-            // Extract determination data
+            // Find enacted determination for timestamp (when loop actually ran)
+            // If no enacted determination exists in last 30 min, use a synthetic timestamp
+            // of "31 minutes ago" so watchface can distinguish between:
+            //   - nil = no data received yet (watch startup)
+            //   - 31+ min old = loop is stale
+            let enactedDetermination = allDeterminationObjects.first(where: { $0.enacted })
+            let enactedTimestamp: Date = enactedDetermination?.timestamp ?? Date().addingTimeInterval(-31 * 60)
+
+            // Extract data values from most recent determination (enacted or suggested)
+            // Suggested sets provide latest calculations even if loop hasn't run yet
             var cobValue: Double?
             var sensRatioValue: Double?
             var isfValue: Int16?
             var eventualBGValue: Int16?
-            var determinationTimestamp: Date?
 
-            if let latestDetermination = determinationObjects.first {
-                determinationTimestamp = latestDetermination.timestamp
+            if let latestDetermination = allDeterminationObjects.first {
                 cobValue = Double(latestDetermination.cob)
 
                 if let ratio = latestDetermination.sensitivityRatio {
@@ -467,12 +530,10 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
 
                 var watchState = GarminWatchState()
 
-                // Timestamp: Use determination timestamp to indicate loop staleness
-                // If loop hasn't run recently, the old determination timestamp shows data is stale
-                // Fall back to glucose timestamp only if no determination exists
+                // Loop timestamp: Only use enacted determination timestamp (never glucose timestamp)
+                // This shows when the loop actually executed, not when glucose was received
                 if index == 0 {
-                    let timestamp = determinationTimestamp ?? glucose.date
-                    watchState.date = timestamp.map { UInt64($0.timeIntervalSince1970 * 1000) }
+                    watchState.date = UInt64(enactedTimestamp.timeIntervalSince1970 * 1000)
                 } else {
                     watchState.date = glucose.date.map { UInt64($0.timeIntervalSince1970 * 1000) }
                 }
@@ -490,6 +551,10 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
                         watchState.delta = 0
                     }
 
+                    // Glucose timestamp: Used by watchface to determine if glucose is fresh
+                    // Enables green coloring when: enacted loop is 6+ min old but glucose is <10 min old
+                    watchState.glucoseDate = glucose.date.map { UInt64($0.timeIntervalSince1970 * 1000) }
+
                     watchState.units_hint = unitsHint
                     watchState.iob = iobValue
                     watchState.cob = cobValue