From abae39af317f21bf2d27f694ed45a02147743b2b Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 8 Jul 2026 11:59:39 -0500 Subject: [PATCH 1/4] OmniTests: faultEventTimeSinceActivation is nil for 0x0000 fault time f6d56f9 made DetailedStatus return nil (not 0.0) when the fault time is 0x0000, a DASH reset-fault sentinel rather than a valid time. Update the two tests still asserting 0.0 (testPodInfoNoFaultAlerts, testPodInfoDuringPriming) to XCTAssertNil, matching the intended behavior. --- OmniTests/PodInfoTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OmniTests/PodInfoTests.swift b/OmniTests/PodInfoTests.swift index 8093962..f10d414 100644 --- a/OmniTests/PodInfoTests.swift +++ b/OmniTests/PodInfoTests.swift @@ -132,7 +132,7 @@ class PodInfoTests: XCTestCase { XCTAssertEqual(0000, decoded.bolusNotDelivered) XCTAssertEqual(0x0a, decoded.lastProgrammingMessageSeqNum) XCTAssertEqual(.noFaults, decoded.faultEventCode.faultType) - XCTAssertEqual(TimeInterval(minutes: 0x0000), decoded.faultEventTimeSinceActivation) + XCTAssertNil(decoded.faultEventTimeSinceActivation) // 0x0000 fault time is a reset-fault sentinel, not a valid time XCTAssertEqual(Pod.reservoirLevelAboveThresholdMagicNumber, decoded.reservoirLevel, accuracy: 0.01) XCTAssertEqual(8100, decoded.timeActive) XCTAssertEqual(TimeInterval(minutes: 0x0087), decoded.timeActive) @@ -193,7 +193,7 @@ class PodInfoTests: XCTestCase { XCTAssertEqual(0, decoded.bolusNotDelivered, accuracy: 0.01) XCTAssertEqual(6, decoded.lastProgrammingMessageSeqNum) XCTAssertEqual(.interlockLoad, decoded.faultEventCode.faultType) - XCTAssertEqual(TimeInterval(minutes: 0x0000), decoded.faultEventTimeSinceActivation) + XCTAssertNil(decoded.faultEventTimeSinceActivation) // 0x0000 fault time is a reset-fault sentinel, not a valid time XCTAssertEqual(Pod.reservoirLevelAboveThresholdMagicNumber, decoded.reservoirLevel, accuracy: 0.01) XCTAssertEqual(TimeInterval(minutes: 0x0000), decoded.timeActive) XCTAssertEqual(0, decoded.unacknowledgedAlerts.rawValue) From 15ce70d3ce548c501b8f8e1f7c7bf1b70a6bfdf1 Mon Sep 17 00:00:00 2001 From: Joe Moran Date: Wed, 15 Jul 2026 17:22:08 -0700 Subject: [PATCH 2/4] Remove unneeded reference to release RLs when switching pump types --- OmnipodKit/PumpManager/OmniPumpManager.swift | 13 ------- .../ViewControllers/OmniUICoordinator.swift | 34 +++++++------------ .../ViewModels/OmniSettingsViewModel.swift | 18 +++------- 3 files changed, 18 insertions(+), 47 deletions(-) diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index 0c677dd..0b8ab4d 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -397,16 +397,6 @@ public class OmniPumpManager: RileyLinkPumpManager { backgroundTask.stopBackgroundTask() } - typealias syncSilencePodStateFuncType = (_ silencePod: Bool, _ silencePodEnd: Date?) -> Void - - /// Function to be called when silencePod variables are updated - private var syncSilencePodState: syncSilencePodStateFuncType? - - /// Initializes the resetSilencePodState var for callbacks when silence pod mode has changed - func setSyncSilencePodState(_ callbackFunc: @escaping syncSilencePodStateFuncType) { - syncSilencePodState = callbackFunc - } - // MARK: - RileyLink specific vars and funcs @@ -663,9 +653,6 @@ extension OmniPumpManager { doSetSilencePod(session: session, silencePod: false, silencePodEnd: nil) { error in if let error = error { self.log.default("@@@ handleSilencePodEnd: disable silence mode failed: %{public}@", error.localizedDescription) - } else { - // Call back to sync the UI silence pod state variables - self.syncSilencePodState?(false, nil) } } } diff --git a/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift b/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift index 2f7b30d..38820c0 100644 --- a/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift +++ b/OmnipodKit/PumpManagerUI/ViewControllers/OmniUICoordinator.swift @@ -25,8 +25,8 @@ enum OmniUIScreen { case insulinTypeSelection case selectPodType case podTypeSelected // virtual routing step — never presented; resolves to o5KeySetup / rileyLinkSetup / pairAndPrime - case o5KeySetup - case rileyLinkSetup // will be skipped for non-Eros pods + case o5KeySetup // O5 only step + case rileyLinkSetup // Eros only step case pairAndPrime case insertCannula case confirmAttachment @@ -90,8 +90,6 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi weak var completionDelegate: CompletionDelegate? - var podType = unknownOmnipodType - var pumpManager: OmniPumpManager private var disposables = Set() @@ -181,7 +179,6 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi case .selectPodType: let didConfirm: (PodType) -> Void = { [weak self] (selectedPodType) in - self?.podType = selectedPodType self?.pumpManager.podType = selectedPodType self?.stepFinished() } @@ -190,7 +187,7 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi } let o5NotAvailable = !isOmnipod5Enabled() - let podTypeSelectionView = PodTypeSelection(initialValue: self.podType, o5NotAvailable: o5NotAvailable, didConfirm: didConfirm, didCancel: didCancel) + let podTypeSelectionView = PodTypeSelection(initialValue: pumpManager.podType, o5NotAvailable: o5NotAvailable, didConfirm: didConfirm, didCancel: didCancel) let hostedView = hostingController(rootView: podTypeSelectionView) hostedView.navigationItem.title = LocalizedString("Pod Type", comment: "Title for Pod Type selection screen") return hostedView @@ -236,7 +233,7 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi // We no longer have the certificate for our controllerId. // Navagiate to select pod type to allow user to select a non O5 pod type // or to confirm/select O5 pod type and then go through the O5 Setup process again. - self?.podType = unknownOmnipodType // forces user to manually select the pod type + self?.pumpManager.podType = unknownOmnipodType // forces user to manually select the pod type self?.navigateTo(.selectPodType) } else { self?.stepFinished() @@ -253,10 +250,6 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi case .settings: let viewModel = OmniSettingsViewModel(pumpManager: pumpManager) viewModel.didFinish = { [weak self] in - if self?.pumpManager.podType == unknownOmnipodType { - print("Resetting OmniUICoordinator podType to unknownOmnipodType") - self?.podType = unknownOmnipodType - } self?.stepFinished() } viewModel.navigateTo = { [weak self] (screen) in @@ -299,7 +292,7 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi } let view = hostingController(rootView: PairPodView(viewModel: viewModel).onAppear(perform: {UIApplication.shared.isIdleTimerDisabled = true}), onDisappear: {UIApplication.shared.isIdleTimerDisabled = false}) - view.navigationItem.title = String(format: LocalizedString("Pair %1$@ Pod", comment: "Title for pod pairing screen (1: pod type brief name)"), self.podType.briefName) + view.navigationItem.title = String(format: LocalizedString("Pair %1$@ Pod", comment: "Title for pod pairing screen (1: pod type brief name)"), pumpManager.podType.briefName) view.navigationItem.backButtonDisplayMode = .generic return view @@ -433,13 +426,13 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi // Any caller asking to start pairing — whether through the routing step // or directly via `.pairAndPrime` (e.g. the Pair Pod button in settings) // gets diverted to the key setup screen if no cert is loaded. - if podType == omnipod5Type && O5CertificateStore.isEmpty { + if pumpManager.podType == omnipod5Type && O5CertificateStore.isEmpty { if screen == .podTypeSelected || screen == .pairAndPrime { return .o5KeySetup } } guard screen == .podTypeSelected else { return screen } - if podType.usesRileyLink { + if pumpManager.podType.usesRileyLink { return .rileyLinkSetup } return .pairAndPrime @@ -473,11 +466,11 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi isOnboarded: false, podState: nil, timeZone: basalSchedule.timeZone, - basalSchedule: BasalSchedule(repeatingScheduleValues: basalSchedule.items, podType: self.podType), + basalSchedule: BasalSchedule(repeatingScheduleValues: basalSchedule.items, podType: unknownOmnipodType), maxBasalRateUnitsPerHour: pumpManagerSettings.maxBasalRateUnitsPerHour, maxBolusUnits: pumpManagerSettings.maxBolusUnits, insulinType: nil, - podType: self.podType) + podType: unknownOmnipodType) self.pumpManager = OmniPumpManager(state: pumpManagerState, rileyLinkDeviceProvider: deviceProvider) } else { @@ -501,26 +494,25 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi } private func determineInitialStep() -> OmniUIScreen { - self.podType = pumpManager.podType if pumpManager.state.podState?.needsCommsRecovery == true { return .pendingCommandRecovery } else if pumpManager.podCommState == .activating { if pumpManager.state.podState?.readyForCannulaInsertion == true && pumpManager.podAttachmentConfirmed { return .insertCannula } else { - assert(self.podType != unknownOmnipodType) + assert(pumpManager.podType != unknownOmnipodType) return .pairAndPrime // need to finish the priming } } else if !pumpManager.isOnboarded { if !pumpManager.initialConfigurationCompleted { return .firstRunScreen } - if self.podType == unknownOmnipodType { + if pumpManager.podType == unknownOmnipodType { return .selectPodType // need to first select a pod type } return .podTypeSelected // route to o5KeySetup / rileyLinkSetup / pairAndPrime as appropriate } else { - if self.podType == unknownOmnipodType { + if pumpManager.podType == unknownOmnipodType { return .selectPodType // need to first select a pod type } // O5 selected, no cert, no active pod: route through pod-type @@ -528,7 +520,7 @@ class OmniUICoordinator: UINavigationController, PumpManagerOnboarding, Completi // into the O5 cert download (via resolveRoutingStep's // .podTypeSelected → .o5KeySetup interception) instead of // landing in Settings where Pair Pod would fail. - if self.podType == omnipod5Type + if pumpManager.podType == omnipod5Type && O5CertificateStore.isEmpty && pumpManager.podCommState == .noPod { return .selectPodType diff --git a/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift b/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift index 83900f3..bf755dc 100644 --- a/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift +++ b/OmnipodKit/PumpManagerUI/ViewModels/OmniSettingsViewModel.swift @@ -93,7 +93,7 @@ class OmniSettingsViewModel: ObservableObject { // Expiration reminder date for current pod @Published var expirationReminderDate: Date? - + var allowedScheduledReminderDates: [Date]? { return pumpManager.allowedExpirationReminderDates } @@ -254,7 +254,7 @@ class OmniSettingsViewModel: ObservableObject { init(pumpManager: OmniPumpManager) { self.pumpManager = pumpManager - + lifeState = pumpManager.lifeState activatedAt = pumpManager.podActivatedAt expiresAt = pumpManager.expiresAt @@ -269,8 +269,8 @@ class OmniSettingsViewModel: ObservableObject { podCommState = pumpManager.podCommState beepPreference = pumpManager.beepPreference silencePodPreference = pumpManager.silencePod ? .enabled : .disabled - podKeepAlivePreference = Storage.shared.podKeepAlive.value silencePodEnd = pumpManager.silencePodEnd + podKeepAlivePreference = Storage.shared.podKeepAlive.value hasConnection = pumpManager.hasConnection insulinType = pumpManager.insulinType podDetails = pumpManager.podDetails @@ -280,8 +280,6 @@ class OmniSettingsViewModel: ObservableObject { pumpManager.addPodStateObserver(self, queue: DispatchQueue.main) pumpManager.addStatusObserver(self, queue: DispatchQueue.main) - pumpManager.setSyncSilencePodState(syncSilencePodState) - // Trigger refresh pumpManager.getPodStatus() { _ in } @@ -405,14 +403,6 @@ class OmniSettingsViewModel: ObservableObject { } } - /// Called by the Omni pump manager after the silencePodEnd reached and silence pod mode disabled. - func syncSilencePodState(_ silencePod: Bool, _ silencePodEnd: Date?) { - DispatchQueue.main.async { - self.silencePodPreference = silencePod ? .enabled : .disabled - self.silencePodEnd = silencePodEnd - } - } - func setSilencePod(_ silencePodPreference: SilencePodPreference, silencePodEnd: Date?, _ completion: @escaping (_ error: LocalizedError?) -> Void) { @@ -583,6 +573,8 @@ extension OmniSettingsViewModel: PodStateObserver { expirationReminderDate = self.pumpManager.scheduledExpirationReminder podCommState = self.pumpManager.podCommState beepPreference = self.pumpManager.beepPreference + silencePodPreference = self.pumpManager.silencePod ? .enabled : .disabled + silencePodEnd = self.pumpManager.silencePodEnd insulinType = self.pumpManager.insulinType podDetails = self.pumpManager.podDetails previousPodDetails = self.pumpManager.previousPodDetails From 716f0f0d52a4133297bf616b299928b0aecc70f2 Mon Sep 17 00:00:00 2001 From: marionbarker Date: Sat, 25 Jul 2026 09:29:48 -0700 Subject: [PATCH 3/4] Add Pete Schwamb as a code owner --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index db37803..58453a1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,4 +3,4 @@ # when "Require review from Code Owners" is enabled in branch protection, # their approval is required before a PR can be merged. -* @marionbarker @itsmojo +* @marionbarker @itsmojo @ps2 From d3a55a43ee0b8a57ef1d64c35798a75a2a6ea6b1 Mon Sep 17 00:00:00 2001 From: marionbarker Date: Sun, 26 Jul 2026 19:30:46 -0700 Subject: [PATCH 4/4] Fix pod pairing retry issues --- OmnipodKit/Bluetooth/BlePodComms.swift | 68 +++++++++++-------- OmnipodKit/PumpManager/OmniPumpManager.swift | 1 - .../ViewModels/PairPodViewModel.swift | 7 +- 3 files changed, 42 insertions(+), 34 deletions(-) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index 2c0e3b8..baf495a 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -77,36 +77,44 @@ class BlePodComms: PodComms { completion(.failure(error)) return } - Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in - let devices = self.bluetoothManager.getConnectedDevices() - - if devices.count > 1 { - self.log.default("Multiple pods found while scanning") - self.bluetoothManager.endPodDiscovery() - completion(.failure(PodCommsError.tooManyPodsFound)) - timer.invalidate() - } - - let elapsed = Date().timeIntervalSince(discoveryStartTime) - - // If we've found a pod by 2 seconds, let's go. - if elapsed > TimeInterval(seconds: 2) && devices.count > 0 { - let targetPod = devices.first! - let uuidString = targetPod.manager.peripheral.identifier.uuidString - self.log.default("Found pod UUID %{public}@!", uuidString) - self.bluetoothManager.connectToDevice(uuidString: uuidString) - self.manager = targetPod.manager - targetPod.manager.delegate = self - self.bluetoothManager.endPodDiscovery() - completion(.success(devices.first!)) - timer.invalidate() - } - - if elapsed > TimeInterval(seconds: 10) { - self.log.default("No pods found while scanning") - self.bluetoothManager.endPodDiscovery() - completion(.failure(PodCommsError.noPodsFound)) - timer.invalidate() + // The discoverPods completion can run on a caller thread with no active + // run loop (e.g. a pairing auto-retry from a background queue), where a + // Timer scheduled on the current thread would never fire and discovery + // would hang without ever timing out. Always schedule on the main run loop. + DispatchQueue.main.async { + Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in + let devices = self.bluetoothManager.getConnectedDevices() + + if devices.count > 1 { + self.log.default("Multiple pods found while scanning") + self.bluetoothManager.endPodDiscovery() + completion(.failure(PodCommsError.tooManyPodsFound)) + timer.invalidate() + return + } + + let elapsed = Date().timeIntervalSince(discoveryStartTime) + + // If we've found a pod by 2 seconds, let's go. + if elapsed > TimeInterval(seconds: 2) && devices.count > 0 { + let targetPod = devices.first! + let uuidString = targetPod.manager.peripheral.identifier.uuidString + self.log.default("Found pod UUID %{public}@!", uuidString) + self.bluetoothManager.connectToDevice(uuidString: uuidString) + self.manager = targetPod.manager + targetPod.manager.delegate = self + self.bluetoothManager.endPodDiscovery() + completion(.success(targetPod)) + timer.invalidate() + return + } + + if elapsed > TimeInterval(seconds: 10) { + self.log.default("No pods found while scanning") + self.bluetoothManager.endPodDiscovery() + completion(.failure(PodCommsError.noPodsFound)) + timer.invalidate() + } } } } diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index 934a563..0741e45 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -1349,7 +1349,6 @@ extension OmniPumpManager { blePodComms.connectToNewPod { result in switch result { case .failure(let error): - completion(.failure(.communication(error as? LocalizedError))) completionFailure(error as? LocalizedError) case .success: // Have new podState, reset all the per pod pump manager state diff --git a/OmnipodKit/PumpManagerUI/ViewModels/PairPodViewModel.swift b/OmnipodKit/PumpManagerUI/ViewModels/PairPodViewModel.swift index a83d78c..aca69c6 100644 --- a/OmnipodKit/PumpManagerUI/ViewModels/PairPodViewModel.swift +++ b/OmnipodKit/PumpManagerUI/ViewModels/PairPodViewModel.swift @@ -218,9 +218,10 @@ class PairPodViewModel: ObservableObject, Identifiable { self.autoRetryAttempted = true let autoRetryPauseTime = TimeInterval(seconds: 3) print("### pairAndPrimePod encountered error \(error.localizedDescription), retrying after \(autoRetryPauseTime) seconds") - DispatchQueue.global(qos: .utility).async { - Thread.sleep(forTimeInterval: autoRetryPauseTime) - + // Retry from the main queue: pairAndPrime() can end up scheduling a + // discovery Timer on the calling thread, which never fires on a + // worker thread with no running run loop. + DispatchQueue.main.asyncAfter(deadline: .now() + autoRetryPauseTime) { self.pairAndPrime() // handles both pairing or priming failures } }