diff --git a/Loop/Extensions/DeviceDataManager+SimpleBolusViewModelDelegate.swift b/Loop/Extensions/DeviceDataManager+SimpleBolusViewModelDelegate.swift index 4192700ef4..2363cad41a 100644 --- a/Loop/Extensions/DeviceDataManager+SimpleBolusViewModelDelegate.swift +++ b/Loop/Extensions/DeviceDataManager+SimpleBolusViewModelDelegate.swift @@ -16,7 +16,8 @@ extension DeviceDataManager: SimpleBolusViewModelDelegate { } func enactBolus(units: Double, activationType: BolusActivationType) { - enactBolus(units: units, activationType: activationType) { (_) in } + // The simple bolus calculator is only ever driven by the user on the phone. + enactBolus(units: units, activationType: activationType, origin: .manual) { (_) in } } func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index ff678a8824..1cc4385335 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -829,24 +829,34 @@ extension DeviceDataManager { // MARK: - Client API extension DeviceDataManager { - func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void = { _ in }) { + func enactBolus(units: Double, activationType: BolusActivationType, origin: BolusOrigin? = nil, completion: @escaping (_ error: Error?) -> Void = { _ in }) { guard let pumpManager = pumpManager else { completion(LoopError.configurationError(.pumpManager)) return } + // Mint the correlation reference only once the command is actually going to the pump, so the guard + // above cannot leave an orphaned origin mapping behind. + let bolusReference = origin.map { BolusOriginStore.shared.makeReference(for: $0) } + self.loopManager.addRequestedBolus(DoseEntry(type: .bolus, startDate: Date(), value: units, unit: .units, isMutable: true)) { - pumpManager.enactBolus(units: units, activationType: activationType) { (error) in + pumpManager.enactBolus(units: units, activationType: activationType, bolusReference: bolusReference) { (error) in if let error = error { self.log.error("%{public}@", String(describing: error)) switch error { case .uncertainDelivery: - // Do not generate notification on uncertain delivery error + // Do not generate notification on uncertain delivery error. Keep the origin mapping: + // the dose may still be reported and reconciled later. break default: + // Definite failure: drop the origin mapping for this request. The origin still rides + // along on the failure notification so a retried bolus keeps its provenance. + if let bolusReference = bolusReference { + BolusOriginStore.shared.remove(reference: bolusReference) + } // Do not generate notifications for automatic boluses that fail. if !activationType.isAutomatic { - NotificationManager.sendBolusFailureNotification(for: error, units: units, at: Date(), activationType: activationType) + NotificationManager.sendBolusFailureNotification(for: error, units: units, at: Date(), activationType: activationType, origin: origin) } } @@ -864,9 +874,9 @@ extension DeviceDataManager { } } - func enactBolus(units: Double, activationType: BolusActivationType) async throws { + func enactBolus(units: Double, activationType: BolusActivationType, origin: BolusOrigin? = nil) async throws { return try await withCheckedThrowingContinuation { continuation in - enactBolus(units: units, activationType: activationType) { error in + enactBolus(units: units, activationType: activationType, origin: origin) { error in if let error = error { continuation.resume(throwing: error) return @@ -1221,6 +1231,17 @@ extension DeviceDataManager: PumpManagerDelegate { dispatchPrecondition(condition: .onQueue(queue)) log.default("PumpManager:%{public}@ hasNewPumpEvents (lastReconciliation = %{public}@)", String(describing: type(of: pumpManager)), String(describing: lastReconciliation)) + // Re-key any tagged bolus origin from its request reference to the identifier the dose store will + // persist as the dose's syncIdentifier and that is looked up at Nightscout-upload time. The dose store + // always derives that identifier from the event's raw bytes (see PumpEvent.syncIdentifier), so key on + // those directly rather than on dose.syncIdentifier, which is only populated when the event was built + // through NewPumpEvent's designated init. + for event in events { + if let dose = event.dose, dose.type == .bolus, let reference = dose.bolusReference { + BolusOriginStore.shared.promoteReference(reference, toSyncIdentifier: event.raw.hexadecimalString) + } + } + doseStore.addPumpEvents(events, lastReconciliation: lastReconciliation, replacePendingEvents: replacePendingEvents) { (error) in if let error = error { self.log.error("Failed to addPumpEvents to DoseStore: %{public}@", String(describing: error)) @@ -1450,7 +1471,7 @@ extension Notification.Name { extension DeviceDataManager: ServicesManagerDosingDelegate { func deliverBolus(amountInUnits: Double) async throws { - try await enactBolus(units: amountInUnits, activationType: .manualNoRecommendation) + try await enactBolus(units: amountInUnits, activationType: .manualNoRecommendation, origin: .remote) } } diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 3f00104c5a..a21546ed43 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -553,8 +553,13 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { startDate.timeIntervalSinceNow >= TimeInterval(minutes: -5) { deviceDataManager?.analyticsServicesManager.didRetryBolus() - - deviceDataManager?.enactBolus(units: units, activationType: activationType) { (_) in + + // Restore the failed bolus's origin if the notification carried one; the retry is still + // user-initiated on the phone, so fall back to .manual. + let origin = (response.notification.request.content.userInfo[LoopNotificationUserInfoKey.bolusOrigin.rawValue] as? String) + .flatMap(BolusOrigin.init(rawValue:)) ?? .manual + + deviceDataManager?.enactBolus(units: units, activationType: activationType, origin: origin) { (_) in DispatchQueue.main.async { completionHandler() } diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 996d147047..da5e517b81 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -79,7 +79,7 @@ extension NotificationManager { // MARK: - Notifications - static func sendBolusFailureNotification(for error: PumpManagerError, units: Double, at startDate: Date, activationType: BolusActivationType) { + static func sendBolusFailureNotification(for error: PumpManagerError, units: Double, at startDate: Date, activationType: BolusActivationType, origin: BolusOrigin? = nil) { let notification = UNMutableNotificationContent() notification.title = NSLocalizedString("Bolus Issue", comment: "The notification title for a bolus issue") @@ -99,11 +99,14 @@ extension NotificationManager { notification.categoryIdentifier = LoopNotificationCategory.bolusFailure.rawValue } - notification.userInfo = [ + var userInfo: [String: Any] = [ LoopNotificationUserInfoKey.bolusAmount.rawValue: units, LoopNotificationUserInfoKey.bolusStartDate.rawValue: startDate, LoopNotificationUserInfoKey.bolusActivationType.rawValue: activationType.rawValue ] + // Carry the origin so a retry from the notification keeps the bolus's provenance. + userInfo[LoopNotificationUserInfoKey.bolusOrigin.rawValue] = origin?.rawValue + notification.userInfo = userInfo let request = UNNotificationRequest( // Only support 1 bolus notification at once diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index bac60b71dc..abc27c92e4 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -384,7 +384,7 @@ final class WatchDataManager: NSObject { return } - deviceManager.enactBolus(units: bolus.value, activationType: bolus.activationType) { (error) in + deviceManager.enactBolus(units: bolus.value, activationType: bolus.activationType, origin: .watch) { (error) in if error == nil { self.deviceManager.analyticsServicesManager.didBolus(source: "Watch", units: bolus.value) } diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index a86f20e0cc..20d2e0317d 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -29,7 +29,7 @@ protocol BolusEntryViewModelDelegate: AnyObject { func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) - func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void) + func enactBolus(units: Double, activationType: BolusActivationType, origin: BolusOrigin?, completion: @escaping (_ error: Error?) -> Void) func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (_ samples: Swift.Result<[StoredGlucoseSample], Error>) -> Void) @@ -421,7 +421,7 @@ final class BolusEntryViewModel: ObservableObject { if amountToDeliver > 0 { savedPreMealOverride = nil - delegate.enactBolus(units: amountToDeliver, activationType: activationType, completion: { _ in + delegate.enactBolus(units: amountToDeliver, activationType: activationType, origin: .manual, completion: { _ in self.analyticsServicesManager?.didBolus(source: "Phone", units: amountToDeliver) }) } diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 7f2c421ebf..6e23d9dfad 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -911,7 +911,7 @@ fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { var enactedBolusUnits: Double? var enactedBolusActivationType: BolusActivationType? - func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (Error?) -> Void) { + func enactBolus(units: Double, activationType: BolusActivationType, origin: BolusOrigin?, completion: @escaping (Error?) -> Void) { enactedBolusUnits = units enactedBolusActivationType = activationType }