Skip to content

Commit

Permalink
Merge release/v1.1.0 into dev (#463)
Browse files Browse the repository at this point in the history
* LOOP-3830 PumpManager sync updates (#456) (#457)

* PumpManager protocol changes for syncing

* Remove debug prints

* LOOP-3856: Adds canceling of temp basal if user lowers max basal below temp basal (#460)

* ckpt

* LOOP-3856: Wire up an "EnactTempBasal" hook for TherapySettings to use

* PR Feedback: moved temp basal validation to LoopDataManager

* Adds unit tests to LoopDataManager for canceling temp basal

* Adds unit tests to LoopDataManager for checking loop is called when changing max basal

* fix tests

* Fix race condition causing intermittent unit test failures

* Sigh...looks like we were "waiting" on the wrong queue

* fix tests

* PR Feedback

* rename validateTempBasal -> validateMaxTempBasal to make it more clear

* Fix comment

* Rename ValidateMaxTempBasal -> MaxTempBasalSavePreflight

* Remove "indirection" in syncBasalRateSchedule and maxTempBasalSavePreflight

* Refactor stuff into new TherapySettingsViewModelDelegate

The number of function aliases in `TherapySettingsViewModel` was getting to be a mess. I consolidated `SaveCompletion`, `MaxTempBasalSavePreflight`, `SyncBasalRateSchedule`, and `SyncDeliveryLimits` all into a new `TherapySettingsViewModelDelegate`. Things got a bit cleaner, and now `DeviceDataManager` handles doing both the `maxTempBasalSavePreflight` and `syncDeliveryLimits` so it is not so loose of a contract.

* PR Feedback

Co-authored-by: Pete Schwamb <[email protected]>
  • Loading branch information
Rick Pasetto and ps2 authored Oct 8, 2021
1 parent b64de1f commit ad465f3
Show file tree
Hide file tree
Showing 6 changed files with 221 additions and 91 deletions.
82 changes: 82 additions & 0 deletions Loop/Managers/DeviceDataManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1400,6 +1400,88 @@ extension DeviceDataManager: SupportInfoProvider {

}

//MARK: TherapySettingsViewModelDelegate
struct CancelTempBasalFailedError: LocalizedError {
let reason: Error?

var errorDescription: String? {
return String(format: NSLocalizedString("%@%@ was unable to cancel your current temporary basal rate, which is higher than the new Max Basal limit you have set. This may result in higher insulin delivery than desired.\n\nConsider suspending insulin delivery manually and then immediately resuming to enact basal delivery with the new limit in place.",
comment: "Alert text for failing to cancel temp basal (1: reason description, 2: app name)"),
reasonString, Bundle.main.bundleDisplayName)
}

private var reasonString: String {
let paragraphEnd = ".\n\n"
if let localizedError = reason as? LocalizedError {
let errors = [localizedError.errorDescription, localizedError.failureReason, localizedError.recoverySuggestion].compactMap { $0 }
if !errors.isEmpty {
return errors.joined(separator: ". ") + paragraphEnd
}
}
return reason.map { $0.localizedDescription + paragraphEnd } ?? ""
}
}

extension DeviceDataManager: TherapySettingsViewModelDelegate {

func syncBasalRateSchedule(items: [RepeatingScheduleValue<Double>], completion: @escaping (Swift.Result<BasalRateSchedule, Error>) -> Void) {
pumpManager?.syncBasalRateSchedule(items: items, completion: completion)
}

func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Swift.Result<DeliveryLimits, Error>) -> Void) {
// FIRST we need to check to make sure if we have to cancel temp basal first
loopManager.maxTempBasalSavePreflight(unitsPerHour: deliveryLimits.maximumBasalRate?.doubleValue(for: .internationalUnitsPerHour)) { [weak self] error in
if let error = error {
completion(.failure(CancelTempBasalFailedError(reason: error)))
} else if let pumpManager = self?.pumpManager {
pumpManager.syncDeliveryLimits(limits: deliveryLimits, completion: completion)
} else {
completion(.success(deliveryLimits))
}
}
}

func saveCompletion(for therapySetting: TherapySetting, therapySettings: TherapySettings) {
switch therapySetting {
case .glucoseTargetRange:
loopManager.mutateSettings { settings in settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule }
case .preMealCorrectionRangeOverride:
loopManager.mutateSettings { settings in settings.preMealTargetRange = therapySettings.correctionRangeOverrides?.preMeal }
case .workoutCorrectionRangeOverride:
loopManager.mutateSettings { settings in settings.legacyWorkoutTargetRange = therapySettings.correctionRangeOverrides?.workout }
case .suspendThreshold:
loopManager.mutateSettings { settings in settings.suspendThreshold = therapySettings.suspendThreshold }
case .basalRate:
loopManager.basalRateSchedule = therapySettings.basalRateSchedule
case .deliveryLimits:
loopManager.mutateSettings { settings in
settings.maximumBasalRatePerHour = therapySettings.maximumBasalRatePerHour
settings.maximumBolus = therapySettings.maximumBolus
}
case .insulinModel:
if let defaultRapidActingModel = therapySettings.defaultRapidActingModel {
loopManager.defaultRapidActingModel = defaultRapidActingModel
}
case .carbRatio:
loopManager.carbRatioSchedule = therapySettings.carbRatioSchedule
analyticsServicesManager.didChangeCarbRatioSchedule()
case .insulinSensitivity:
loopManager.insulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule
analyticsServicesManager.didChangeInsulinSensitivitySchedule()
case .none:
break // NO-OP
}
}

func pumpSupportedIncrements() -> PumpSupportedIncrements? {
return pumpManager.map {
PumpSupportedIncrements(basalRates: $0.supportedBasalRates,
bolusVolumes: $0.supportedBolusVolumes,
maximumBasalScheduleEntryCount: $0.maximumBasalScheduleEntryCount)
}
}
}

extension DeviceDataManager {
func addDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) {
let queue = DispatchQueue.main
Expand Down
42 changes: 36 additions & 6 deletions Loop/Managers/LoopDataManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -571,20 +571,27 @@ extension LoopDataManager {
// Cancel active high temp basal
cancelActiveTempBasal()
}

/// Cancel the active temp basal
func cancelActiveTempBasal() {
guard case .tempBasal(_) = basalDeliveryState else { return }

dataAccessQueue.async {
self.recommendedAutomaticDose = (recommendation: AutomaticDoseRecommendation(basalAdjustment: .cancel, bolusUnits: 0), date: self.now())
self.enactRecommendedAutomaticDose { (error) -> Void in
self.storeDosingDecision(withDate: self.now(), withError: error)
self.notify(forChange: .tempBasal)
}
self.cancelActiveTempBasal(completion: nil)
}
}

private func cancelActiveTempBasal(completion: ((Error?) -> Void)?) {
dispatchPrecondition(condition: .onQueue(dataAccessQueue))
recommendedAutomaticDose = (recommendation: AutomaticDoseRecommendation(basalAdjustment: .cancel, bolusUnits: 0), date: self.now())
enactRecommendedAutomaticDose { (error) -> Void in
self.storeDosingDecision(withDate: self.now(), withError: error)
self.notify(forChange: .tempBasal)
completion?(error)
}
}


/// Adds and stores carb data, and recommends a bolus if needed
///
/// - Parameters:
Expand Down Expand Up @@ -1622,6 +1629,29 @@ extension LoopDataManager {
}
}
}

/// Ensures that the current temp basal is at or below the proposed max temp basal, and if not, cancel it before proceeding.
/// Calls the completion with `nil` if successful, or an `error` if canceling the active temp basal fails.
func maxTempBasalSavePreflight(unitsPerHour: Double?, completion: @escaping (_ error: Error?) -> Void) {
guard let unitsPerHour = unitsPerHour else {
completion(nil)
return
}
dataAccessQueue.async {
switch self.basalDeliveryState {
case .some(.tempBasal(let dose)):
if dose.unitsPerHour > unitsPerHour {
// Temp basal is higher than proposed rate, so should cancel
self.cancelActiveTempBasal(completion: completion)
} else {
completion(nil)
}
default:
completion(nil)
}
}
}

}

/// Describes a view into the loop state
Expand Down
49 changes: 1 addition & 48 deletions Loop/View Controllers/StatusTableViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1384,19 +1384,6 @@ final class StatusTableViewController: LoopChartsTableViewController {
didTapAddDevice: { [weak self] in
self?.addCGMManager(withIdentifier: $0.identifier)
})
let pumpSupportedIncrements = { [weak self] in
self?.deviceManager.pumpManager.map {
PumpSupportedIncrements(basalRates: $0.supportedBasalRates,
bolusVolumes: $0.supportedBolusVolumes,
maximumBasalScheduleEntryCount: $0.maximumBasalScheduleEntryCount)
}
}
let syncBasalRateSchedule = { [weak self] in
self?.deviceManager.pumpManager?.syncBasalRateSchedule
}
let syncDeliveryLimits = { [weak self] in
self?.deviceManager.pumpManager?.syncDeliveryLimits
}
let servicesViewModel = ServicesViewModel(showServices: FeatureFlags.includeServicesInSettingsEnabled,
availableServices: { [weak self] in self?.deviceManager.servicesManager.availableServices ?? [] },
activeServices: { [weak self] in self?.deviceManager.servicesManager.activeServices ?? [] },
Expand All @@ -1407,16 +1394,14 @@ final class StatusTableViewController: LoopChartsTableViewController {
servicesViewModel: servicesViewModel,
criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: deviceManager.criticalEventLogExportManager),
therapySettings: { [weak self] in self?.deviceManager.loopManager.therapySettings ?? TherapySettings() },
pumpSupportedIncrements: pumpSupportedIncrements,
syncPumpSchedule: syncBasalRateSchedule,
syncDeliveryLimits: syncDeliveryLimits,
sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled,
initialDosingEnabled: deviceManager.loopManager.settings.dosingEnabled,
isClosedLoopAllowed: closedLoopStatus.$isClosedLoopAllowed,
supportInfoProvider: deviceManager,
dosingStrategy: deviceManager.loopManager.settings.dosingStrategy,
availableSupports: deviceManager.availableSupports,
isOnboardingComplete: onboardingManager.isComplete,
therapySettingsViewModelDelegate: deviceManager,
delegate: self)
let hostingController = DismissibleHostingController(
rootView: SettingsView(viewModel: viewModel)
Expand Down Expand Up @@ -2010,38 +1995,6 @@ extension StatusTableViewController: SettingsViewModelDelegate {
}
}

func didSave(therapySetting: TherapySetting, therapySettings: TherapySettings) {
switch therapySetting {
case .glucoseTargetRange:
deviceManager?.loopManager.mutateSettings { settings in settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule }
case .preMealCorrectionRangeOverride:
deviceManager?.loopManager.mutateSettings { settings in settings.preMealTargetRange = therapySettings.correctionRangeOverrides?.preMeal }
case .workoutCorrectionRangeOverride:
deviceManager?.loopManager.mutateSettings { settings in settings.legacyWorkoutTargetRange = therapySettings.correctionRangeOverrides?.workout }
case .suspendThreshold:
deviceManager?.loopManager.mutateSettings { settings in settings.suspendThreshold = therapySettings.suspendThreshold }
case .basalRate:
deviceManager?.loopManager.basalRateSchedule = therapySettings.basalRateSchedule
case .deliveryLimits:
deviceManager?.loopManager.mutateSettings { settings in
settings.maximumBasalRatePerHour = therapySettings.maximumBasalRatePerHour
settings.maximumBolus = therapySettings.maximumBolus
}
case .insulinModel:
if let defaultRapidActingModel = therapySettings.defaultRapidActingModel {
deviceManager?.loopManager.defaultRapidActingModel = defaultRapidActingModel
}
case .carbRatio:
deviceManager?.loopManager.carbRatioSchedule = therapySettings.carbRatioSchedule
deviceManager?.analyticsServicesManager.didChangeCarbRatioSchedule()
case .insulinSensitivity:
deviceManager?.loopManager.insulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule
deviceManager?.analyticsServicesManager.didChangeInsulinSensitivitySchedule()
case .none:
break // NO-OP
}
}

func didTapIssueReport(title: String) {
// TODO: this dismiss here is temporary, until we know exactly where
// we want this screen to belong in the navigation flow
Expand Down
18 changes: 3 additions & 15 deletions Loop/View Models/SettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ public typealias PumpManagerViewModel = DeviceViewModel<PumpManagerDescriptor>
public protocol SettingsViewModelDelegate: AnyObject {
func dosingEnabledChanged(_: Bool)
func dosingStrategyChanged(_: DosingStrategy)
func didSave(therapySetting: TherapySetting, therapySettings: TherapySettings)
func didTapIssueReport(title: String)
}

Expand All @@ -66,10 +65,6 @@ public class SettingsViewModel: ObservableObject {
notificationsCriticalAlertPermissionsViewModel.showWarning
}

var didSave: TherapySettingsViewModel.SaveCompletion? {
delegate?.didSave
}

var didTapIssueReport: ((String) -> Void)? {
delegate?.didTapIssueReport
}
Expand All @@ -80,13 +75,10 @@ public class SettingsViewModel: ObservableObject {
let servicesViewModel: ServicesViewModel
let criticalEventLogExportViewModel: CriticalEventLogExportViewModel
let therapySettings: () -> TherapySettings
// TODO This pattern of taking a closure that returns a closure is redundant; we should simplify here.
let pumpSupportedIncrements: (() -> PumpSupportedIncrements?)?
let syncPumpSchedule: (() -> SyncSchedule?)?
let syncDeliveryLimits: (() -> SyncDeliveryLimits?)?
let sensitivityOverridesEnabled: Bool
let supportInfoProvider: SupportInfoProvider
let isOnboardingComplete: Bool
let therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate?

@Published var isClosedLoopAllowed: Bool
@Published var dosingStrategy: DosingStrategy {
Expand All @@ -109,16 +101,14 @@ public class SettingsViewModel: ObservableObject {
servicesViewModel: ServicesViewModel,
criticalEventLogExportViewModel: CriticalEventLogExportViewModel,
therapySettings: @escaping () -> TherapySettings,
pumpSupportedIncrements: (() -> PumpSupportedIncrements?)?,
syncPumpSchedule: (() -> SyncSchedule?)?,
syncDeliveryLimits: (() -> SyncDeliveryLimits?)?,
sensitivityOverridesEnabled: Bool,
initialDosingEnabled: Bool,
isClosedLoopAllowed: Published<Bool>.Publisher,
supportInfoProvider: SupportInfoProvider,
dosingStrategy: DosingStrategy,
availableSupports: [SupportUI],
isOnboardingComplete: Bool,
therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate?,
delegate: SettingsViewModelDelegate?
) {
self.notificationsCriticalAlertPermissionsViewModel = notificationsCriticalAlertPermissionsViewModel
Expand All @@ -127,16 +117,14 @@ public class SettingsViewModel: ObservableObject {
self.servicesViewModel = servicesViewModel
self.criticalEventLogExportViewModel = criticalEventLogExportViewModel
self.therapySettings = therapySettings
self.pumpSupportedIncrements = pumpSupportedIncrements
self.syncPumpSchedule = syncPumpSchedule
self.syncDeliveryLimits = syncDeliveryLimits
self.sensitivityOverridesEnabled = sensitivityOverridesEnabled
self.closedLoopPreference = initialDosingEnabled
self.isClosedLoopAllowed = false
self.dosingStrategy = dosingStrategy
self.supportInfoProvider = supportInfoProvider
self.availableSupports = availableSupports
self.isOnboardingComplete = isOnboardingComplete
self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate
self.delegate = delegate

// This strangeness ensures the composed ViewModels' (ObservableObjects') changes get reported to this ViewModel (ObservableObject)
Expand Down
10 changes: 2 additions & 8 deletions Loop/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,7 @@ extension SettingsView {
.sheet(isPresented: $therapySettingsIsPresented) {
TherapySettingsView(mode: .settings,
viewModel: TherapySettingsViewModel(therapySettings: self.viewModel.therapySettings(),
pumpSupportedIncrements: self.viewModel.pumpSupportedIncrements,
syncPumpSchedule: self.viewModel.syncPumpSchedule,
syncDeliveryLimits: self.viewModel.syncDeliveryLimits,
sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled,
didSave: self.viewModel.didSave))
delegate: self.viewModel.therapySettingsViewModelDelegate))
.environmentObject(displayGlucoseUnitObservable)
.environment(\.dismissAction, self.dismiss)
.environment(\.appName, self.appName)
Expand Down Expand Up @@ -415,16 +411,14 @@ public struct SettingsView_Previews: PreviewProvider {
servicesViewModel: servicesViewModel,
criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: MockCriticalEventLogExporterFactory()),
therapySettings: { TherapySettings() },
pumpSupportedIncrements: nil,
syncPumpSchedule: nil,
syncDeliveryLimits: nil,
sensitivityOverridesEnabled: false,
initialDosingEnabled: true,
isClosedLoopAllowed: fakeClosedLoopAllowedPublisher.$mockIsClosedLoopAllowed,
supportInfoProvider: MockSupportInfoProvider(),
dosingStrategy: .automaticBolus,
availableSupports: [],
isOnboardingComplete: false,
therapySettingsViewModelDelegate: nil,
delegate: nil)
return Group {
SettingsView(viewModel: viewModel)
Expand Down
Loading

0 comments on commit ad465f3

Please sign in to comment.