|
|
@@ -8,6 +8,11 @@ import Swinject
|
|
|
|
|
|
protocol APSManager {
|
|
|
func heartbeat(date: Date)
|
|
|
+ /// Mark the next loop attempt as user-initiated (e.g. force-loop button).
|
|
|
+ /// Surfaces transient errors immediately instead of waiting for the
|
|
|
+ /// usual dwell threshold — when the user explicitly asks for a loop,
|
|
|
+ /// they want feedback even if the underlying error is "transient".
|
|
|
+ func markNextLoopUserInitiated()
|
|
|
func enactBolus(amount: Double, isSMB: Bool, callback: ((Bool, String) -> Void)?) async
|
|
|
var pumpManager: PumpManagerUI? { get set }
|
|
|
var bluetoothManager: BluetoothStateManager? { get }
|
|
|
@@ -36,6 +41,13 @@ protocol APSManager {
|
|
|
var iobFileDidUpdate: PassthroughSubject<Void, Never> { get }
|
|
|
}
|
|
|
|
|
|
+/// Notified after a bolus-related failure so observing UI (e.g. the
|
|
|
+/// treatment screen's bolus state) can clean up state. Broadcast by
|
|
|
+/// `APSManager` from `enactBolus` / `cancelBolus` error paths.
|
|
|
+protocol BolusFailureObserver {
|
|
|
+ func bolusDidFail()
|
|
|
+}
|
|
|
+
|
|
|
enum APSError: LocalizedError {
|
|
|
case pumpError(Error)
|
|
|
case invalidPumpState(message: String)
|
|
|
@@ -57,16 +69,27 @@ enum APSError: LocalizedError {
|
|
|
return String(localized: "Manual Temporary Basal Rate (\(message)). Looping suspended.")
|
|
|
}
|
|
|
}
|
|
|
+}
|
|
|
|
|
|
- static func pumpErrorMatches(message: String) -> Bool {
|
|
|
- message.contains(String(localized: "Pump Error"))
|
|
|
+// MARK: - Thread-safe loop serialization
|
|
|
+
|
|
|
+/// Ensures only one loop runs at a time via actor isolation
|
|
|
+private actor LoopGuard {
|
|
|
+ private var isRunning = false
|
|
|
+
|
|
|
+ /// Atomically checks whether a new loop can start and marks it as running if so.
|
|
|
+ func tryStart(minInterval: TimeInterval, lastLoopDate: Date, lastLoopStartDate: Date) -> Bool {
|
|
|
+ // If the last loop completed after it started, enforce minimum interval
|
|
|
+ if lastLoopDate > lastLoopStartDate {
|
|
|
+ guard lastLoopStartDate.addingTimeInterval(minInterval) < Date() else { return false }
|
|
|
+ }
|
|
|
+ guard !isRunning else { return false }
|
|
|
+ isRunning = true
|
|
|
+ return true
|
|
|
}
|
|
|
|
|
|
- static func pumpWarningMatches(message: String) -> Bool {
|
|
|
- message.contains(String(localized: "Invalid Pump State")) || message
|
|
|
- .contains("PumpMessage") || message
|
|
|
- .contains("PumpOpsError") || message.contains("RileyLink") || message
|
|
|
- .contains(String(localized: "Pump did not respond in time"))
|
|
|
+ func finish() {
|
|
|
+ isRunning = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -82,6 +105,7 @@ final class BaseAPSManager: APSManager, Injectable {
|
|
|
@Injected() private var settingsManager: SettingsManager!
|
|
|
@Injected() private var tddStorage: TDDStorage!
|
|
|
@Injected() private var broadcaster: Broadcaster!
|
|
|
+ @Injected() private var trioAlertManager: TrioAlertManager!
|
|
|
@Persisted(key: "lastLoopStartDate") private var lastLoopStartDate: Date = .distantPast
|
|
|
@Persisted(key: "lastLoopDate") var lastLoopDate: Date = .distantPast {
|
|
|
didSet {
|
|
|
@@ -89,14 +113,18 @@ final class BaseAPSManager: APSManager, Injectable {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- let viewContext = CoreDataStack.shared.persistentContainer.viewContext
|
|
|
let privateContext = CoreDataStack.shared.newTaskContext()
|
|
|
|
|
|
private var openAPS: OpenAPS!
|
|
|
|
|
|
private var lifetime = Lifetime()
|
|
|
|
|
|
- private var backgroundTaskID: UIBackgroundTaskIdentifier?
|
|
|
+ private let loopGuard = LoopGuard()
|
|
|
+ /// All reads/writes are dispatched onto `processQueue` so the bolus
|
|
|
+ /// trigger sink, `cancelBolus`, and the `DoseProgressReporter`
|
|
|
+ /// callback (which the pump manager already invokes on
|
|
|
+ /// `processQueue`) all serialize through one queue
|
|
|
+ private var bolusReporter: DoseProgressReporter?
|
|
|
|
|
|
var pumpManager: PumpManagerUI? {
|
|
|
get { deviceDataManager.pumpManager }
|
|
|
@@ -156,7 +184,7 @@ final class BaseAPSManager: APSManager, Injectable {
|
|
|
if wasParsed {
|
|
|
Task {
|
|
|
do {
|
|
|
- try await openAPS.createProfiles(useSwiftOref: settings.useSwiftOref)
|
|
|
+ try await openAPS.createProfiles(useJavascriptOref: settings.useJavascriptOref)
|
|
|
} catch {
|
|
|
debug(
|
|
|
.apsManager,
|
|
|
@@ -178,46 +206,48 @@ final class BaseAPSManager: APSManager, Injectable {
|
|
|
deviceDataManager.errorSubject
|
|
|
.receive(on: processQueue)
|
|
|
.map { APSError.pumpError($0) }
|
|
|
- .sink {
|
|
|
- self.processError($0)
|
|
|
+ .sink { [weak self] in
|
|
|
+ self?.processError($0)
|
|
|
}
|
|
|
.store(in: &lifetime)
|
|
|
|
|
|
deviceDataManager.bolusTrigger
|
|
|
.receive(on: processQueue)
|
|
|
- .sink { bolusing in
|
|
|
+ .sink { [weak self] bolusing in
|
|
|
if bolusing {
|
|
|
- self.createBolusReporter()
|
|
|
+ self?.createBolusReporter()
|
|
|
} else {
|
|
|
- self.clearBolusReporter()
|
|
|
+ self?.clearBolusReporter()
|
|
|
}
|
|
|
}
|
|
|
.store(in: &lifetime)
|
|
|
|
|
|
+ // The following three publishers update `@Persisted` properties that
|
|
|
+ // are also read from the main thread (UI bindings)
|
|
|
deviceDataManager.scheduledBasal
|
|
|
- .receive(on: processQueue)
|
|
|
- .sink { scheduledBasal in
|
|
|
- self.isScheduledBasal = scheduledBasal
|
|
|
+ .receive(on: DispatchQueue.main)
|
|
|
+ .sink { [weak self] scheduledBasal in
|
|
|
+ self?.isScheduledBasal = scheduledBasal
|
|
|
}
|
|
|
.store(in: &lifetime)
|
|
|
|
|
|
deviceDataManager.suspended
|
|
|
- .receive(on: processQueue)
|
|
|
- .sink { suspended in
|
|
|
- self.isSuspended = suspended
|
|
|
+ .receive(on: DispatchQueue.main)
|
|
|
+ .sink { [weak self] suspended in
|
|
|
+ self?.isSuspended = suspended
|
|
|
}
|
|
|
.store(in: &lifetime)
|
|
|
|
|
|
// manage a manual Temp Basal from PumpManager - force loop() after manual temp basal is cancelled or finishes
|
|
|
deviceDataManager.manualTempBasal
|
|
|
- .receive(on: processQueue)
|
|
|
- .sink { manualBasal in
|
|
|
+ .receive(on: DispatchQueue.main)
|
|
|
+ .sink { [weak self] manualBasal in
|
|
|
if manualBasal {
|
|
|
- self.isManualTempBasal = true
|
|
|
+ self?.isManualTempBasal = true
|
|
|
} else {
|
|
|
- if self.isManualTempBasal {
|
|
|
- self.isManualTempBasal = false
|
|
|
- self.loop()
|
|
|
+ if self?.isManualTempBasal == true {
|
|
|
+ self?.isManualTempBasal = false
|
|
|
+ self?.loop()
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -233,116 +263,115 @@ final class BaseAPSManager: APSManager, Injectable {
|
|
|
Task { [weak self] in
|
|
|
guard let self else { return }
|
|
|
|
|
|
- // Check if we can start a new loop
|
|
|
- guard await self.canStartNewLoop() else { return }
|
|
|
+ // Consume the user-initiated flag unconditionally — it was set
|
|
|
+ // for the loop the user just triggered. If the guards below block
|
|
|
+ // (suspended, too-soon, no pump), the next scheduled tick must
|
|
|
+ // not inherit it and bypass dwell suppression for an error the
|
|
|
+ // user didn't request.
|
|
|
+ let userInitiated = self.nextLoopUserInitiated
|
|
|
+ self.nextLoopUserInitiated = false
|
|
|
+
|
|
|
+ // Don't try to run a loop while pump setup / pod pairing is in
|
|
|
+ // progress — `verifyStatus` would throw `invalidPumpState("Pump
|
|
|
+ // not set")` and surface a modal banner on top of the pod
|
|
|
+ // activation sheet, closing the sheet (reported by tester during
|
|
|
+ // O5 pairing).
|
|
|
+ guard self.pumpManager != nil else {
|
|
|
+ debug(.apsManager, "No pump manager — skipping loop attempt")
|
|
|
+ return
|
|
|
+ }
|
|
|
|
|
|
- // Setup loop and background task
|
|
|
- var (loopStatRecord, backgroundTask) = await self.setupLoop()
|
|
|
+ // Atomic check-and-set via actor — eliminates the race between
|
|
|
+ // checking isLooping.value and sending isLooping(true).
|
|
|
+ guard await loopGuard.tryStart(
|
|
|
+ minInterval: Config.loopInterval,
|
|
|
+ lastLoopDate: lastLoopDate,
|
|
|
+ lastLoopStartDate: lastLoopStartDate
|
|
|
+ ) else {
|
|
|
+ debug(.apsManager, "Loop skipped (already running or too soon)")
|
|
|
+ return
|
|
|
+ }
|
|
|
|
|
|
- do {
|
|
|
- // Execute loop logic
|
|
|
- try await self.executeLoop(loopStatRecord: &loopStatRecord)
|
|
|
+ // Affects whether transient errors surface immediately instead of
|
|
|
+ // dwell-suppressed (see `surfaceErrorIfNeeded`).
|
|
|
+ self.currentLoopUserInitiated = userInitiated
|
|
|
+ defer { self.currentLoopUserInitiated = false }
|
|
|
+
|
|
|
+ // Start background task
|
|
|
+ // we probably need to refactor this when implementing Swift 6 due to mutation of a captured var in an async context
|
|
|
+ var taskID: UIBackgroundTaskIdentifier = .invalid
|
|
|
+ taskID = await UIApplication.shared.beginBackgroundTask(withName: "Loop starting") {
|
|
|
+ // closure runs on the Main Thread
|
|
|
+ // removed the Task that provided no guarantee to end the background task
|
|
|
+ if taskID != .invalid {
|
|
|
+ UIApplication.shared.endBackgroundTask(taskID)
|
|
|
+ taskID = .invalid
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ isLooping.send(true)
|
|
|
+
|
|
|
+ let loopStartDate = Date()
|
|
|
+ lastLoopStartDate = loopStartDate
|
|
|
+ let interval = await calculateLoopInterval(loopStartDate: loopStartDate)
|
|
|
+
|
|
|
+ var loopStatRecord = LoopStats(
|
|
|
+ start: loopStartDate,
|
|
|
+ loopStatus: "Starting",
|
|
|
+ interval: interval
|
|
|
+ )
|
|
|
|
|
|
+ do {
|
|
|
+ try await executeLoop(loopStatRecord: &loopStatRecord)
|
|
|
requestNightscoutUpload(
|
|
|
[.carbs, .pumpHistory, .overrides, .tempTargets],
|
|
|
source: "APSManager"
|
|
|
)
|
|
|
+ await finalizeLoop(loopStatRecord: loopStatRecord)
|
|
|
} catch {
|
|
|
- var updatedStats = loopStatRecord
|
|
|
- updatedStats.end = Date()
|
|
|
- updatedStats.duration = roundDouble((updatedStats.end! - updatedStats.start).timeInterval / 60, 2)
|
|
|
- updatedStats.loopStatus = error.localizedDescription
|
|
|
- await loopCompleted(error: error, loopStatRecord: updatedStats)
|
|
|
+ let endDate = Date()
|
|
|
+ loopStatRecord.end = endDate
|
|
|
+ loopStatRecord.duration = roundDouble((endDate - loopStatRecord.start).timeInterval / 60, 2)
|
|
|
+ loopStatRecord.loopStatus = error.localizedDescription
|
|
|
+ await finalizeLoop(error: error, loopStatRecord: loopStatRecord)
|
|
|
debug(.apsManager, "\(DebuggingIdentifiers.failed) Failed to complete Loop: \(error)")
|
|
|
}
|
|
|
|
|
|
- // Cleanup background task
|
|
|
- if let backgroundTask = backgroundTask {
|
|
|
- await UIApplication.shared.endBackgroundTask(backgroundTask)
|
|
|
- self.backgroundTaskID = .invalid
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- private func canStartNewLoop() async -> Bool {
|
|
|
- // Check if too soon for next loop
|
|
|
- if lastLoopDate > lastLoopStartDate {
|
|
|
- guard lastLoopStartDate.addingTimeInterval(Config.loopInterval) < Date() else {
|
|
|
- debug(.apsManager, "Not enough time have passed since last loop at : \(lastLoopStartDate)")
|
|
|
- return false
|
|
|
+ // End the background task
|
|
|
+ if taskID != .invalid {
|
|
|
+ await UIApplication.shared.endBackgroundTask(taskID)
|
|
|
+ taskID = .invalid
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- // Check if loop already in progress
|
|
|
- guard !isLooping.value else {
|
|
|
- warning(.apsManager, "Loop already in progress. Skip recommendation.")
|
|
|
- return false
|
|
|
- }
|
|
|
-
|
|
|
- return true
|
|
|
- }
|
|
|
-
|
|
|
- private func setupLoop() async -> (LoopStats, UIBackgroundTaskIdentifier?) {
|
|
|
- // Start background task
|
|
|
- let backgroundTask = await UIApplication.shared.beginBackgroundTask(withName: "Loop starting") { [weak self] in
|
|
|
- guard let self, let backgroundTask = self.backgroundTaskID else { return }
|
|
|
- Task {
|
|
|
- UIApplication.shared.endBackgroundTask(backgroundTask)
|
|
|
- }
|
|
|
- self.backgroundTaskID = .invalid
|
|
|
- }
|
|
|
- backgroundTaskID = backgroundTask
|
|
|
-
|
|
|
- // Set loop start time
|
|
|
- lastLoopStartDate = Date()
|
|
|
-
|
|
|
- // Calculate interval from previous loop
|
|
|
- let interval = await calculateLoopInterval()
|
|
|
-
|
|
|
- // Create initial loop stats record
|
|
|
- let loopStatRecord = LoopStats(
|
|
|
- start: lastLoopStartDate,
|
|
|
- loopStatus: "Starting",
|
|
|
- interval: interval
|
|
|
- )
|
|
|
-
|
|
|
- isLooping.send(true)
|
|
|
-
|
|
|
- return (loopStatRecord, backgroundTask)
|
|
|
}
|
|
|
|
|
|
private func executeLoop(loopStatRecord: inout LoopStats) async throws {
|
|
|
try await determineBasal()
|
|
|
|
|
|
- // Handle open loop
|
|
|
- guard settings.closedLoop else {
|
|
|
- loopStatRecord.end = Date()
|
|
|
- loopStatRecord.duration = roundDouble((loopStatRecord.end! - loopStatRecord.start).timeInterval / 60, 2)
|
|
|
- loopStatRecord.loopStatus = "Success"
|
|
|
- await loopCompleted(loopStatRecord: loopStatRecord)
|
|
|
- return
|
|
|
+ // Closed loop: also enact the determination.
|
|
|
+ if settings.closedLoop {
|
|
|
+ try await enactDetermination()
|
|
|
}
|
|
|
|
|
|
- // Handle closed loop
|
|
|
- try await enactDetermination()
|
|
|
- loopStatRecord.end = Date()
|
|
|
- loopStatRecord.duration = roundDouble((loopStatRecord.end! - loopStatRecord.start).timeInterval / 60, 2)
|
|
|
+ let endDate = Date()
|
|
|
+ loopStatRecord.end = endDate
|
|
|
+ loopStatRecord.duration = roundDouble((endDate - loopStatRecord.start).timeInterval / 60, 2)
|
|
|
loopStatRecord.loopStatus = "Success"
|
|
|
- await loopCompleted(loopStatRecord: loopStatRecord)
|
|
|
}
|
|
|
|
|
|
- private func calculateLoopInterval() async -> Double? {
|
|
|
+ private func calculateLoopInterval(loopStartDate: Date) async -> Double? {
|
|
|
do {
|
|
|
- return try await privateContext.perform {
|
|
|
+ return try await privateContext.perform { [weak self] in
|
|
|
+ guard let self else { return nil }
|
|
|
let requestStats = LoopStatRecord.fetchRequest() as NSFetchRequest<LoopStatRecord>
|
|
|
let sortStats = NSSortDescriptor(key: "end", ascending: false)
|
|
|
requestStats.sortDescriptors = [sortStats]
|
|
|
requestStats.fetchLimit = 1
|
|
|
let previousLoop = try self.privateContext.fetch(requestStats)
|
|
|
|
|
|
- if (previousLoop.first?.end ?? .distantFuture) < self.lastLoopStartDate {
|
|
|
+ if (previousLoop.first?.end ?? .distantFuture) < loopStartDate {
|
|
|
return self.roundDouble(
|
|
|
- (self.lastLoopStartDate - (previousLoop.first?.end ?? Date())).timeInterval / 60,
|
|
|
+ (loopStartDate - (previousLoop.first?.end ?? Date())).timeInterval / 60,
|
|
|
1
|
|
|
)
|
|
|
}
|
|
|
@@ -354,21 +383,20 @@ final class BaseAPSManager: APSManager, Injectable {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // Loop exit point
|
|
|
- private func loopCompleted(error: Error? = nil, loopStatRecord: LoopStats) async {
|
|
|
+ /// Single exit point for loop — replaces the old `loopCompleted()`.
|
|
|
+ private func finalizeLoop(error: Error? = nil, loopStatRecord: LoopStats) async {
|
|
|
+ await loopGuard.finish()
|
|
|
isLooping.send(false)
|
|
|
|
|
|
if let error = error {
|
|
|
warning(.apsManager, "Loop failed with error: \(error)")
|
|
|
- if let backgroundTask = backgroundTaskID {
|
|
|
- await UIApplication.shared.endBackgroundTask(backgroundTask)
|
|
|
- backgroundTaskID = .invalid
|
|
|
- }
|
|
|
processError(error)
|
|
|
} else {
|
|
|
debug(.apsManager, "Loop succeeded")
|
|
|
lastLoopDate = Date()
|
|
|
lastError.send(nil)
|
|
|
+ transientCategoryFirstSeen.removeAll()
|
|
|
+ transientCategoryCount.removeAll()
|
|
|
}
|
|
|
|
|
|
loopStats(loopStatRecord: loopStatRecord)
|
|
|
@@ -376,12 +404,6 @@ final class BaseAPSManager: APSManager, Injectable {
|
|
|
if settings.closedLoop {
|
|
|
await reportEnacted(wasEnacted: error == nil)
|
|
|
}
|
|
|
-
|
|
|
- // End of the BG tasks
|
|
|
- if let backgroundTask = backgroundTaskID {
|
|
|
- await UIApplication.shared.endBackgroundTask(backgroundTask)
|
|
|
- backgroundTaskID = .invalid
|
|
|
- }
|
|
|
}
|
|
|
|
|
|
private func verifyStatus() -> Error? {
|
|
|
@@ -412,7 +434,7 @@ final class BaseAPSManager: APSManager, Injectable {
|
|
|
else {
|
|
|
let result = try await openAPS.autosense(
|
|
|
shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
|
|
|
- useSwiftOref: settings.useSwiftOref
|
|
|
+ useJavascriptOref: settings.useJavascriptOref
|
|
|
)
|
|
|
return result != nil
|
|
|
}
|
|
|
@@ -478,16 +500,15 @@ final class BaseAPSManager: APSManager, Injectable {
|
|
|
do {
|
|
|
let now = Date()
|
|
|
|
|
|
- // Parallelize the fetches using async let
|
|
|
- async let currentTemp = fetchCurrentTempBasal(date: now)
|
|
|
- async let autosenseResult = autosense()
|
|
|
+ // put profile creation up front since autosens needs it
|
|
|
+ try await openAPS.createProfiles(useJavascriptOref: settings.useJavascriptOref)
|
|
|
+ let currentTemp = try await fetchCurrentTempBasal(date: now)
|
|
|
+ _ = try await autosense()
|
|
|
|
|
|
- _ = try await autosenseResult
|
|
|
- try await openAPS.createProfiles(useSwiftOref: settings.useSwiftOref)
|
|
|
let determination = try await openAPS.determineBasal(
|
|
|
- currentTemp: await currentTemp,
|
|
|
+ currentTemp: currentTemp,
|
|
|
shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
|
|
|
- useSwiftOref: settings.useSwiftOref,
|
|
|
+ useJavascriptOref: settings.useJavascriptOref,
|
|
|
clock: now
|
|
|
)
|
|
|
iobFileDidUpdate.send(())
|
|
|
@@ -534,7 +555,7 @@ final class BaseAPSManager: APSManager, Injectable {
|
|
|
return try await openAPS.determineBasal(
|
|
|
currentTemp: temp,
|
|
|
shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
|
|
|
- useSwiftOref: settings.useSwiftOref,
|
|
|
+ useJavascriptOref: settings.useJavascriptOref,
|
|
|
clock: Date(),
|
|
|
simulatedCarbsAmount: simulatedCarbsAmount,
|
|
|
simulatedBolusAmount: simulatedBolusAmount,
|
|
|
@@ -556,8 +577,6 @@ final class BaseAPSManager: APSManager, Injectable {
|
|
|
return min(rounded, maxBolus)
|
|
|
}
|
|
|
|
|
|
- private var bolusReporter: DoseProgressReporter?
|
|
|
-
|
|
|
func enactBolus(amount: Double, isSMB: Bool, callback: ((Bool, String) -> Void)?) async {
|
|
|
if amount <= 0 {
|
|
|
return
|
|
|
@@ -602,9 +621,14 @@ final class BaseAPSManager: APSManager, Injectable {
|
|
|
}
|
|
|
} catch {
|
|
|
warning(.apsManager, "Bolus failed with error: \(error)")
|
|
|
- processError(APSError.pumpError(error))
|
|
|
+ lastError.send(APSError.pumpError(error))
|
|
|
+ issueAlertForCategory(
|
|
|
+ .bolusFailed,
|
|
|
+ title: String(localized: "Bolus failed"),
|
|
|
+ body: String(localized: "Check pump history before repeating.")
|
|
|
+ + "\n\n\(error.localizedDescription)"
|
|
|
+ )
|
|
|
if !isSMB {
|
|
|
- // Use MainActor to handle broadcaster notification
|
|
|
let broadcaster = self.broadcaster
|
|
|
Task { @MainActor in
|
|
|
broadcaster?.notify(BolusFailureObserver.self, on: .main) {
|
|
|
@@ -628,7 +652,12 @@ final class BaseAPSManager: APSManager, Injectable {
|
|
|
callback?(true, String(localized: "Bolus cancelled successfully.", comment: "Success message for canceling a bolus"))
|
|
|
} catch {
|
|
|
debug(.apsManager, "Bolus cancellation failed with error: \(error)")
|
|
|
- processError(APSError.pumpError(error))
|
|
|
+ lastError.send(APSError.pumpError(error))
|
|
|
+ issueAlertForCategory(
|
|
|
+ .bolusFailed,
|
|
|
+ title: String(localized: "Bolus cancellation failed"),
|
|
|
+ body: String(localized: "Try again.") + "\n\n\(error.localizedDescription)"
|
|
|
+ )
|
|
|
callback?(
|
|
|
false,
|
|
|
String(
|
|
|
@@ -637,9 +666,7 @@ final class BaseAPSManager: APSManager, Injectable {
|
|
|
)
|
|
|
)
|
|
|
}
|
|
|
- bolusReporter?.removeObserver(self)
|
|
|
- bolusReporter = nil
|
|
|
- bolusProgress.send(nil)
|
|
|
+ clearBolusReporter()
|
|
|
}
|
|
|
|
|
|
func enactTempBasal(rate: Double, duration: TimeInterval) async {
|
|
|
@@ -719,10 +746,9 @@ final class BaseAPSManager: APSManager, Injectable {
|
|
|
throw APSError.apsError(message: "Pump not set")
|
|
|
}
|
|
|
|
|
|
- // Check if pump is suspended and abort if it is
|
|
|
if pump.status.pumpStatus.suspended {
|
|
|
- info(.apsManager, "Skipping enactDetermination because pump is suspended")
|
|
|
- return // return without throwing an error
|
|
|
+ debug(.apsManager, "Skipping enactDetermination because pump is suspended")
|
|
|
+ return
|
|
|
}
|
|
|
|
|
|
// Unable to do temp basal during manual temp basal 😁
|
|
|
@@ -838,31 +864,31 @@ final class BaseAPSManager: APSManager, Injectable {
|
|
|
return Double(sorted[length / 2])
|
|
|
}
|
|
|
|
|
|
+ /// Computes Time-in-Range statistics. Must be called from inside a
|
|
|
+ /// `privateContext.perform` block — the inner perform was redundant
|
|
|
private func tir(_ glucose: [GlucoseStored]) -> (TIR: Double, hypos: Double, hypers: Double, normal_: Double) {
|
|
|
- privateContext.perform {
|
|
|
- let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
|
|
|
- let totalReadings = justGlucoseArray.count
|
|
|
- let highLimit = settingsManager.settings.high
|
|
|
- let lowLimit = settingsManager.settings.low
|
|
|
- let hyperArray = glucose.filter({ $0.glucose >= Int(highLimit) })
|
|
|
- let hyperReadings = hyperArray.compactMap({ each in each.glucose as Int16 }).count
|
|
|
- let hyperPercentage = Double(hyperReadings) / Double(totalReadings) * 100
|
|
|
- let hypoArray = glucose.filter({ $0.glucose <= Int(lowLimit) })
|
|
|
- let hypoReadings = hypoArray.compactMap({ each in each.glucose as Int16 }).count
|
|
|
- let hypoPercentage = Double(hypoReadings) / Double(totalReadings) * 100
|
|
|
- // Euglyccemic range
|
|
|
- let normalArray = glucose.filter({ $0.glucose >= 70 && $0.glucose <= 140 })
|
|
|
- let normalReadings = normalArray.compactMap({ each in each.glucose as Int16 }).count
|
|
|
- let normalPercentage = Double(normalReadings) / Double(totalReadings) * 100
|
|
|
- // TIR
|
|
|
- let tir = 100 - (hypoPercentage + hyperPercentage)
|
|
|
- return (
|
|
|
- roundDouble(tir, 1),
|
|
|
- roundDouble(hypoPercentage, 1),
|
|
|
- roundDouble(hyperPercentage, 1),
|
|
|
- roundDouble(normalPercentage, 1)
|
|
|
- )
|
|
|
- }
|
|
|
+ let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
|
|
|
+ let totalReadings = justGlucoseArray.count
|
|
|
+ let highLimit = settingsManager.settings.high
|
|
|
+ let lowLimit = settingsManager.settings.low
|
|
|
+ let hyperArray = glucose.filter({ $0.glucose >= Int(highLimit) })
|
|
|
+ let hyperReadings = hyperArray.compactMap({ each in each.glucose as Int16 }).count
|
|
|
+ let hyperPercentage = Double(hyperReadings) / Double(totalReadings) * 100
|
|
|
+ let hypoArray = glucose.filter({ $0.glucose <= Int(lowLimit) })
|
|
|
+ let hypoReadings = hypoArray.compactMap({ each in each.glucose as Int16 }).count
|
|
|
+ let hypoPercentage = Double(hypoReadings) / Double(totalReadings) * 100
|
|
|
+ // Euglycemic range
|
|
|
+ let normalArray = glucose.filter({ $0.glucose >= 70 && $0.glucose <= 140 })
|
|
|
+ let normalReadings = normalArray.compactMap({ each in each.glucose as Int16 }).count
|
|
|
+ let normalPercentage = Double(normalReadings) / Double(totalReadings) * 100
|
|
|
+ // TIR
|
|
|
+ let tir = 100 - (hypoPercentage + hyperPercentage)
|
|
|
+ return (
|
|
|
+ roundDouble(tir, 1),
|
|
|
+ roundDouble(hypoPercentage, 1),
|
|
|
+ roundDouble(hyperPercentage, 1),
|
|
|
+ roundDouble(normalPercentage, 1)
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
private func glucoseStats(_ fetchedGlucose: [GlucoseStored])
|
|
|
@@ -1060,17 +1086,14 @@ final class BaseAPSManager: APSManager, Injectable {
|
|
|
let glucose24h = try await fetchGlucose(predicate: NSPredicate.predicateForOneDayAgo, fetchLimit: 288, batchSize: 50)
|
|
|
let glucoseOneWeek = try await fetchGlucose(
|
|
|
predicate: NSPredicate.predicateForOneWeek,
|
|
|
- fetchLimit: 288 * 7,
|
|
|
batchSize: 250
|
|
|
)
|
|
|
let glucoseOneMonth = try await fetchGlucose(
|
|
|
predicate: NSPredicate.predicateForOneMonth,
|
|
|
- fetchLimit: 288 * 7 * 30,
|
|
|
batchSize: 500
|
|
|
)
|
|
|
let glucoseThreeMonths = try await fetchGlucose(
|
|
|
predicate: NSPredicate.predicateForThreeMonths,
|
|
|
- fetchLimit: 288 * 7 * 30 * 3,
|
|
|
batchSize: 1000
|
|
|
)
|
|
|
|
|
|
@@ -1196,7 +1219,8 @@ final class BaseAPSManager: APSManager, Injectable {
|
|
|
}
|
|
|
|
|
|
private func loopStats(loopStatRecord: LoopStats) {
|
|
|
- privateContext.perform {
|
|
|
+ privateContext.perform { [weak self] in
|
|
|
+ guard let self else { return }
|
|
|
let nLS = LoopStatRecord(context: self.privateContext)
|
|
|
nLS.start = loopStatRecord.start
|
|
|
nLS.end = loopStatRecord.end ?? Date()
|
|
|
@@ -1213,20 +1237,126 @@ final class BaseAPSManager: APSManager, Injectable {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ private var transientCategoryFirstSeen: [String: Date] = [:]
|
|
|
+ private var transientCategoryCount: [String: Int] = [:]
|
|
|
+ private static let transientDwellThreshold: TimeInterval = 60
|
|
|
+ private static let transientCountThreshold = 2
|
|
|
+
|
|
|
+ /// Set by `markNextLoopUserInitiated()` (e.g. force-loop button), consumed
|
|
|
+ /// on the next entry into `loop()` so that errors during a user-initiated
|
|
|
+ /// loop surface immediately instead of being suppressed by dwell logic.
|
|
|
+ @SyncAccess private var nextLoopUserInitiated: Bool = false
|
|
|
+ private var currentLoopUserInitiated: Bool = false
|
|
|
+
|
|
|
+ func markNextLoopUserInitiated() {
|
|
|
+ nextLoopUserInitiated = true
|
|
|
+ }
|
|
|
+
|
|
|
private func processError(_ error: Error) {
|
|
|
warning(.apsManager, "\(error)")
|
|
|
lastError.send(error)
|
|
|
+ surfaceErrorIfNeeded(error)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func surfaceErrorIfNeeded(_ error: Error) {
|
|
|
+ let category = TrioAlertClassifier.categorize(error: error)
|
|
|
+ let key = String(describing: category)
|
|
|
+
|
|
|
+ if category.shouldFireImmediately || currentLoopUserInitiated {
|
|
|
+ transientCategoryFirstSeen.removeValue(forKey: key)
|
|
|
+ transientCategoryCount.removeValue(forKey: key)
|
|
|
+ issueAlertForError(error, category: category)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ let now = Date()
|
|
|
+ let firstSeen = transientCategoryFirstSeen[key] ?? now
|
|
|
+ let count = (transientCategoryCount[key] ?? 0) + 1
|
|
|
+ let dwellElapsed = now.timeIntervalSince(firstSeen)
|
|
|
+ let dwellMet = dwellElapsed >= Self.transientDwellThreshold
|
|
|
+ let countMet = count >= Self.transientCountThreshold
|
|
|
+
|
|
|
+ if dwellMet || countMet {
|
|
|
+ transientCategoryFirstSeen.removeValue(forKey: key)
|
|
|
+ transientCategoryCount.removeValue(forKey: key)
|
|
|
+ issueAlertForError(error, category: category)
|
|
|
+ } else {
|
|
|
+ transientCategoryFirstSeen[key] = firstSeen
|
|
|
+ transientCategoryCount[key] = count
|
|
|
+ debug(
|
|
|
+ .apsManager,
|
|
|
+ "APSManager suppressed transient \(category) (count=\(count)/\(Self.transientCountThreshold), dwell=\(Int(dwellElapsed))s/\(Int(Self.transientDwellThreshold))s)"
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func issueAlertForCategory(_ category: TrioAlertCategory, title: String, body: String) {
|
|
|
+ let content = Alert.Content(
|
|
|
+ title: title,
|
|
|
+ body: body,
|
|
|
+ acknowledgeActionButtonLabel: String(localized: "OK")
|
|
|
+ )
|
|
|
+ let alert = Alert(
|
|
|
+ identifier: Alert.Identifier(managerIdentifier: "trio.aps", alertIdentifier: category.alertIdentifier),
|
|
|
+ foregroundContent: content,
|
|
|
+ backgroundContent: content,
|
|
|
+ trigger: .immediate,
|
|
|
+ interruptionLevel: category.interruptionLevel
|
|
|
+ )
|
|
|
+ trioAlertManager?.issueAlert(alert)
|
|
|
}
|
|
|
|
|
|
+ private func issueAlertForError(_ error: Error, category: TrioAlertCategory) {
|
|
|
+ let (title, body) = describeForAlert(error)
|
|
|
+ let content = Alert.Content(
|
|
|
+ title: title,
|
|
|
+ body: body,
|
|
|
+ acknowledgeActionButtonLabel: "OK"
|
|
|
+ )
|
|
|
+ let alert = Alert(
|
|
|
+ identifier: Alert.Identifier(managerIdentifier: "trio.aps", alertIdentifier: category.alertIdentifier),
|
|
|
+ foregroundContent: content,
|
|
|
+ backgroundContent: content,
|
|
|
+ trigger: .immediate,
|
|
|
+ interruptionLevel: category.interruptionLevel
|
|
|
+ )
|
|
|
+ trioAlertManager?.issueAlert(alert)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func describeForAlert(_ error: Error) -> (title: String, body: String) {
|
|
|
+ if let apsError = error as? APSError {
|
|
|
+ switch apsError {
|
|
|
+ case let .pumpError(inner):
|
|
|
+ return (
|
|
|
+ String(localized: "Pump Error"),
|
|
|
+ String(localized: "Trio could not communicate with the pump. Check the pump and try again.")
|
|
|
+ + "\n\n\(inner.localizedDescription)"
|
|
|
+ )
|
|
|
+ case let .invalidPumpState(message): return (String(localized: "Pump State Error"), message)
|
|
|
+ case let .glucoseError(message): return (String(localized: "Glucose Error"), message)
|
|
|
+ case let .apsError(message): return (String(localized: "Algorithm Error"), message)
|
|
|
+ case let .manualBasalTemp(message): return (String(localized: "Manual Temp Basal Active"), message)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return ("Trio", error.localizedDescription)
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Called from the `bolusTrigger` Combine sink (already on
|
|
|
+ /// `processQueue`) and from `doseProgressReporterDidUpdate` (the
|
|
|
+ /// pump manager schedules the callback on `processQueue` too).
|
|
|
+ /// Mutations are dispatched onto the queue regardless, so a future
|
|
|
+ /// caller from another context (e.g. `cancelBolus`) stays safe.
|
|
|
private func createBolusReporter() {
|
|
|
- bolusReporter = pumpManager?.createBolusProgressReporter(reportingOn: processQueue)
|
|
|
- bolusReporter?.addObserver(self)
|
|
|
+ processQueue.async {
|
|
|
+ self.bolusReporter = self.pumpManager?.createBolusProgressReporter(reportingOn: self.processQueue)
|
|
|
+ self.bolusReporter?.addObserver(self)
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
private func clearBolusReporter() {
|
|
|
- bolusReporter?.removeObserver(self)
|
|
|
- bolusReporter = nil
|
|
|
- processQueue.asyncAfter(deadline: .now() + 0.5) {
|
|
|
+ processQueue.async {
|
|
|
+ self.bolusReporter?.removeObserver(self)
|
|
|
+ self.bolusReporter = nil
|
|
|
self.bolusProgress.send(nil)
|
|
|
}
|
|
|
}
|
|
|
@@ -1307,7 +1437,8 @@ extension BaseAPSManager: PumpManagerStatusObserver {
|
|
|
func pumpManager(_: PumpManager, didUpdate status: PumpManagerStatus, oldStatus _: PumpManagerStatus) {
|
|
|
let percent = Int((status.pumpBatteryChargeRemaining ?? 1) * 100)
|
|
|
|
|
|
- privateContext.perform {
|
|
|
+ privateContext.perform { [weak self] in
|
|
|
+ guard let self else { return }
|
|
|
/// only update the last item with the current battery infos instead of saving a new one each time
|
|
|
let fetchRequest: NSFetchRequest<OpenAPS_Battery> = OpenAPS_Battery.fetchRequest()
|
|
|
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
|