From 773c65cface3b199db6de53c8efb2d613e7261ca Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 30 Jun 2026 17:15:13 -0500 Subject: [PATCH 01/92] PROTOTYPE: unsolicited pod-fault listener (stage 1: capture + decrypt-log) While connected, a pod can initiate a transfer (fault/alert) over the TP DATA/CMD characteristics without us sending a command. Today those notifications are buffered then flushed before the next command, so faults are only learned by polling GetStatus. Stage 1 (this commit), gated OFF by default behind UserDefaults key OmnipodKit.unsolicitedFaultListenerEnabled: - Detect a pod-initiated transfer while idle (Dash: unsolicited RTS on cmd char; O5: data seq 0 on data char). - Reuse the existing readMessagePacket() receive path on the serial sessionQueue to assemble the encrypted MessagePacket. - Decrypt it (pure EnDecrypt) against a window of nonceSeq offsets and log which offset succeeds + the decrypted payload + all live sequence state. - Does NOT mutate session sequence state and does NOT route alerts. Purpose: confirm from field logs that pods push faults unsolicited, and capture the nonceSeq offset needed to build stage 2 (advance live state + route to FaultEventCode) correctly, instead of guessing sequence handling into dosing comms. --- OmnipodKit/Bluetooth/BlePodComms.swift | 43 +++++++++++++ OmnipodKit/Bluetooth/BlePodProfile.swift | 2 + .../PeripheralManager+OmnipodKit.swift | 61 +++++++++++++++++++ OmnipodKit/Bluetooth/PeripheralManager.swift | 19 ++++++ 4 files changed, 125 insertions(+) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index 2df476e..1769f1b 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -761,3 +761,46 @@ extension BlePodComms: PodCommsSessionDelegate { podState = state } } + +// MARK: - PROTOTYPE: unsolicited (pod-initiated) fault decrypt + logging — Stage 1 +extension BlePodComms { + func peripheralManager(_ manager: PeripheralManager, didReceiveUnsolicitedMessagePacket packet: MessagePacket) { + podStateLock.lock() + let mtsSnapshot = podState?.bleMessageTransportState + podStateLock.unlock() + + guard let mts = mtsSnapshot, let ck = mts.ck, let noncePrefix = mts.noncePrefix, noncePrefix.count == 8 else { + log.error("[unsolicited] cannot decrypt: no established session keys (havePodState=%{public}@)", String(describing: podState != nil)) + return + } + + let enDecrypt = EnDecrypt(nonce: Nonce(prefix: noncePrefix), ck: ck) + + log.default("[unsolicited] decrypt attempt. live state: nonceSeq=%{public}d msgSeq=%{public}d eapSeq=%{public}d messageNumber=%{public}d. packet seq=%{public}d type=%{public}@ encPayloadLen=%{public}d", + mts.nonceSeq, mts.msgSeq, mts.eapSeq, mts.messageNumber, packet.sequenceNumber, String(describing: packet.type), packet.payload.count) + + // A normal response decrypts at nonceSeq+1 (readAndAckResponse increments nonceSeq + // before decrypt). An unsolicited message may land at a different offset; the offset + // that decrypts is the key datum stage 2 needs to advance live state correctly. + let base = mts.nonceSeq + for delta in [1, 0, 2, 3, -1] { + let seq = base + delta + guard seq >= 0 else { continue } + do { + let decrypted = try enDecrypt.decrypt(packet, seq) + log.default("[unsolicited] DECRYPT OK at nonceSeq=%{public}d (delta=%{public}d). decryptedPayloadLen=%{public}d decryptedPayload=%{public}@", + seq, delta, decrypted.payload.count, decrypted.payload.hexadecimalString) + // STAGE 1 ENDS HERE — log only. We deliberately do NOT advance live + // nonceSeq/msgSeq/messageNumber and do NOT route the alert, because the + // correct advancement (and whether pods push these at all) is exactly what + // these field logs confirm. STAGE 2: advance bleMessageTransportState by the + // proven delta and feed `decrypted` through parseResponse -> fault handling. + return + } catch { + log.debug("[unsolicited] decrypt miss at nonceSeq=%{public}d (delta=%{public}d): %{public}@", seq, delta, String(describing: error)) + } + } + log.error("[unsolicited] DECRYPT FAILED at all tried offsets (base nonceSeq=%{public}d). encPayload=%{public}@", + base, packet.payload.hexadecimalString) + } +} diff --git a/OmnipodKit/Bluetooth/BlePodProfile.swift b/OmnipodKit/Bluetooth/BlePodProfile.swift index 6aeeed0..62af27b 100644 --- a/OmnipodKit/Bluetooth/BlePodProfile.swift +++ b/OmnipodKit/Bluetooth/BlePodProfile.swift @@ -75,6 +75,7 @@ struct BlePodProfile { manager.cmdQueue.append(value) manager.queueLock.signal() manager.queueLock.unlock() + manager.noteInboundValueForUnsolicitedListener(characteristicUUID: characteristic.uuid, value: value) }, dataCharacteristicUUID: { (manager: PeripheralManager) in guard let characteristic = manager.peripheral.getDataCharacteristic(profile: manager.profile) else { return } @@ -85,6 +86,7 @@ struct BlePodProfile { manager.dataQueue.append(value) manager.queueLock.signal() manager.queueLock.unlock() + manager.noteInboundValueForUnsolicitedListener(characteristicUUID: characteristic.uuid, value: value) } ] diff --git a/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift b/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift index 60211fa..81f1473 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift @@ -359,3 +359,64 @@ extension PeripheralManagerError { } } } + +// MARK: - PROTOTYPE: unsolicited (pod-initiated) fault listener — Stage 1 (capture + decrypt-log) +// +// While CONNECTED, a pod can initiate a transfer (e.g. a fault/alert) without us having +// sent a command. Today those notifications are only buffered and then flushed +// (clearCommsQueues) before the next command, so we only learn of faults by polling +// GetStatus. This stage detects a pod-initiated transfer while idle, drives the EXISTING +// receive path to assemble the encrypted MessagePacket, and hands it to the delegate to +// decrypt + log. It intentionally does NOT mutate session sequence state and does NOT +// route alerts — its job is to confirm from field logs that pods push faults unsolicited +// and to capture the nonce-sequence behavior stage 2 needs. +extension PeripheralManager { + + /// Off by default. Enable for field testing only: + /// UserDefaults.standard.set(true, forKey: "OmnipodKit.unsolicitedFaultListenerEnabled") + static var unsolicitedFaultListenerEnabled: Bool { + UserDefaults.standard.bool(forKey: "OmnipodKit.unsolicitedFaultListenerEnabled") + } + + /// Called from the cmd/data value-update macros (BLE callback thread) AFTER the raw + /// value is buffered. Must be cheap and non-blocking. Detects the start of a + /// pod-initiated transfer while idle and schedules a serialized receive. + func noteInboundValueForUnsolicitedListener(characteristicUUID: CBUUID, value: Data) { + guard PeripheralManager.unsolicitedFaultListenerEnabled else { return } + guard isIdleForUnsolicitedListener else { return } // a response we're awaiting — not unsolicited + guard peripheral.state == .connected else { return } + + // Transfer-start signal differs by pod type: + // - Dash: an RTS (0x00) on the command characteristic (pod requests to send). + // - O5: the first data packet (seq 0) on the data characteristic (no RTS/CTS). + let isDashStart = podType.isDash + && characteristicUUID == profile.commandCharacteristicUUID + && value.first == PodCommand.RTS.rawValue + let isO5Start = podType.isO5 + && characteristicUUID == profile.dataCharacteristicUUID + && value.first == 0 + guard isDashStart || isO5Start else { return } + + log.default("[unsolicited] candidate pod-initiated transfer while idle: char=%{public}@ first=0x%{public}02x len=%{public}d raw=%{public}@", + characteristicUUID.uuidString, value.first ?? 0, value.count, value.hexadecimalString) + + runSession(withName: "UnsolicitedReceive") { [weak self] in + guard let self = self else { return } + // A real command may have been scheduled and flushed the queues before this + // serialized op ran. + guard PeripheralManager.unsolicitedFaultListenerEnabled, self.peripheral.state == .connected else { return } + do { + guard let packet = try self.readMessagePacket() else { + self.log.default("[unsolicited] no packet assembled (likely flushed by an intervening command)") + return + } + self.log.default("[unsolicited] assembled packet: type=%{public}@ seq=%{public}d encPayloadLen=%{public}d encPayload=%{public}@", + String(describing: packet.type), packet.sequenceNumber, packet.payload.count, packet.payload.hexadecimalString) + self.delegate?.peripheralManager(self, didReceiveUnsolicitedMessagePacket: packet) + } catch { + self.log.error("[unsolicited] receive/assemble failed: %{public}@ (peripheral state=%{public}@)", + String(describing: error), String(describing: self.peripheral.state)) + } + } + } +} diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index d253af4..fccdda8 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -118,6 +118,25 @@ extension PeripheralManager { protocol PeripheralManagerDelegate: AnyObject { // Called from the PeripheralManager's queue func completeConfiguration(for manager: PeripheralManager) throws + + /// PROTOTYPE (unsolicited-fault listener): a fully-assembled MessagePacket that + /// arrived UNSOLICITED — i.e. the pod initiated a transfer while we had no command + /// in flight. The implementer (BlePodComms) holds the session keys and decrypts + + /// logs it. Default is a no-op. Gated by `PeripheralManager.unsolicitedFaultListenerEnabled`. + func peripheralManager(_ manager: PeripheralManager, didReceiveUnsolicitedMessagePacket packet: MessagePacket) +} + +extension PeripheralManagerDelegate { + func peripheralManager(_ manager: PeripheralManager, didReceiveUnsolicitedMessagePacket packet: MessagePacket) {} +} + +extension PeripheralManager { + /// True when no command session is queued/running — used by the unsolicited-fault + /// listener to decide whether an inbound notification is pod-initiated (vs. a + /// response we're waiting for). `sessionQueue` is private to this file. + var isIdleForUnsolicitedListener: Bool { + return sessionQueue.operationCount == 0 + } } From 5f5977a5e67be22d187e19cff337f3e757195024 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 30 Jun 2026 17:15:45 -0500 Subject: [PATCH 02/92] FIELD TEST: default the unsolicited listener ON (revert before merge) --- OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift b/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift index 81f1473..f67c844 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift @@ -372,10 +372,11 @@ extension PeripheralManagerError { // and to capture the nonce-sequence behavior stage 2 needs. extension PeripheralManager { - /// Off by default. Enable for field testing only: - /// UserDefaults.standard.set(true, forKey: "OmnipodKit.unsolicitedFaultListenerEnabled") + /// FIELD TEST BUILD: defaults ON so the listener runs without UI to set the flag. + /// REVERT this commit before merge — stage 1 must ship OFF by default. The UserDefaults + /// key still overrides (set it to false to disable on-device). static var unsolicitedFaultListenerEnabled: Bool { - UserDefaults.standard.bool(forKey: "OmnipodKit.unsolicitedFaultListenerEnabled") + UserDefaults.standard.object(forKey: "OmnipodKit.unsolicitedFaultListenerEnabled") as? Bool ?? true } /// Called from the cmd/data value-update macros (BLE callback thread) AFTER the raw From 88ccec84d6688f88d3ad2d9f15ebd081bccce5e9 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 30 Jun 2026 17:40:59 -0500 Subject: [PATCH 03/92] Fix listener false-triggering during session negotiation (caused disconnect loop) Field log showed the listener firing on a 5-byte cmd-char handshake (00000100f4) DURING session key negotiation, then readMessagePacket timing out and DISCONNECTING the pod -> reconnect loop. Three fixes: - Arm the listener only after an encrypted session is established (manager.unsolicitedListenerArmed, set by BlePodComms: false on connect/disconnect, true after establishNewSession). The ~idle window during negotiation is no longer observed. - Dash RTS trigger now requires a SINGLE 0x00 byte; multi-byte 0x00-prefixed handshakes are excluded. - readMessagePacket gains disconnectOnUnresponsivePod (default true); the observer path passes false so a timeout is silent and never cancels the connection. --- OmnipodKit/Bluetooth/BlePodComms.swift | 3 +++ .../Bluetooth/PeripheralManager+OmnipodKit.swift | 13 +++++++++---- OmnipodKit/Bluetooth/PeripheralManager.swift | 6 ++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index 1769f1b..4a8586e 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -682,6 +682,7 @@ extension BlePodComms: OmniConnectionDelegate { if let podState = podState, manager.peripheral.identifier.uuidString == podState.bleIdentifier { log.bleDebug("omnipodPeripheralDidConnect for %@", manager.peripheral.identifier.uuidString) needsSessionEstablishment = true + manager.unsolicitedListenerArmed = false // re-negotiating; don't observe handshake traffic self.manager = manager delegate?.omnipodPeripheralDidConnect(manager: manager) } @@ -690,6 +691,7 @@ extension BlePodComms: OmniConnectionDelegate { func omnipodPeripheralDidDisconnect(peripheral: CBPeripheral, error: Error?) { if let podState = podState, peripheral.identifier.uuidString == podState.bleIdentifier { log.bleDebug("omnipodPeripheralDidDisconnect for %@", peripheral.identifier.uuidString) + manager?.unsolicitedListenerArmed = false delegate?.omnipodPeripheralDidDisconnect(peripheral: peripheral, error: error) } } @@ -739,6 +741,7 @@ extension BlePodComms: PeripheralManagerDelegate { try manager.enableNotifications() // Seemingly this cannot be done before the hello command, or the pod disconnects try establishNewSession() needsSessionEstablishment = false + manager.unsolicitedListenerArmed = true // encrypted session ready; safe to observe pod-initiated transfers delegate?.podCommsDidEstablishSession(self) } catch { log.error("Pod session sync error: %{public}@", String(describing: error)) diff --git a/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift b/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift index f67c844..c06a2da 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift @@ -123,7 +123,7 @@ extension PeripheralManager { } /// - Throws: PeripheralManagerError - func readMessagePacket() throws -> MessagePacket? { + func readMessagePacket(disconnectOnUnresponsivePod: Bool = true) throws -> MessagePacket? { dispatchPrecondition(condition: .onQueue(queue)) var packet: MessagePacket? @@ -182,7 +182,7 @@ extension PeripheralManager { String(describing: error), String(describing: peripheral.state)) if let error = error as? PeripheralManagerError, error.isSymptomaticOfUnresponsivePod { - if peripheral.state == .connected { + if disconnectOnUnresponsivePod, peripheral.state == .connected { log.error("[readMessagePacket] Disconnecting due to unresponsive pod error while reading") central?.cancelPeripheralConnection(peripheral) } else { @@ -384,14 +384,18 @@ extension PeripheralManager { /// pod-initiated transfer while idle and schedules a serialized receive. func noteInboundValueForUnsolicitedListener(characteristicUUID: CBUUID, value: Data) { guard PeripheralManager.unsolicitedFaultListenerEnabled else { return } + guard unsolicitedListenerArmed else { return } // only after an encrypted session is established guard isIdleForUnsolicitedListener else { return } // a response we're awaiting — not unsolicited guard peripheral.state == .connected else { return } // Transfer-start signal differs by pod type: - // - Dash: an RTS (0x00) on the command characteristic (pod requests to send). + // - Dash: a SINGLE-byte RTS (0x00) on the command characteristic. Multi-byte values + // whose first byte is 0x00 are session-negotiation handshakes (e.g. 00000100f4), + // NOT an RTS — exclude them. // - O5: the first data packet (seq 0) on the data characteristic (no RTS/CTS). let isDashStart = podType.isDash && characteristicUUID == profile.commandCharacteristicUUID + && value.count == 1 && value.first == PodCommand.RTS.rawValue let isO5Start = podType.isO5 && characteristicUUID == profile.dataCharacteristicUUID @@ -407,7 +411,8 @@ extension PeripheralManager { // serialized op ran. guard PeripheralManager.unsolicitedFaultListenerEnabled, self.peripheral.state == .connected else { return } do { - guard let packet = try self.readMessagePacket() else { + // NEVER disconnect from the observer path — a timeout here must be silent. + guard let packet = try self.readMessagePacket(disconnectOnUnresponsivePod: false) else { self.log.default("[unsolicited] no packet assembled (likely flushed by an intervening command)") return } diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index fccdda8..eb4fd11 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -79,6 +79,12 @@ class PeripheralManager: NSObject { weak var delegate: PeripheralManagerDelegate? + /// PROTOTYPE: armed only once an encrypted session is fully established (set by + /// BlePodComms). The unsolicited-fault listener must NOT engage during connect/session + /// negotiation — that traffic looks like pod-initiated transfers (multi-byte handshakes + /// whose first byte is 0x00) and false-triggered a disconnect loop. + var unsolicitedListenerArmed = false + init(peripheral: CBPeripheral, podType: PodType, centralManager: CBCentralManager) { self.peripheral = peripheral self.central = centralManager From e4642440c8dcafa4e6e9c2d2a8d10019f360c84a Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 30 Jun 2026 20:34:46 -0500 Subject: [PATCH 04/92] PROTOTYPE stage 2: register periodic status push + commit push nonce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per binary analysis: the pod is request/response on the connected link and only ORIGINATES a status frame (carrying fault/alert flags) if we register periodic status. Arms the dead listener without polling. - configurePeriodicStatus(): post-establishment, best-guess SN0.0=,GN0.0 (feature N0/attr 0, ASCII decimal 60s) via the existing sendO5AidCommand path. UNCONFIRMED envelope — non-fatal, logs the exact bytes + ACCEPTED/REJECTED so we iterate feature/attr/encoding from field logs. - Listener stage 2a: on a successful decrypt, commit the nonce advance (bleMessageTransportState.nonceSeq = winning offset) so a push doesn't desync the next command; logs decrypted payload (hex + ASCII). Stage 2b (parse->notifyPodFault) deferred until decrypt is confirmed in the field. Gated by the same field-test flag. Every guess is a labeled knob. --- OmnipodKit/Bluetooth/BlePodComms.swift | 61 +++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index 4a8586e..990ae64 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -346,6 +346,44 @@ class BlePodComms: PodComms { // The podState's bleMessageTransportState will be updated when the above defer block is executed. } + // MARK: - PROTOTYPE: periodic-status registration (arms the pod to push) + + /// BEST-GUESS, UNCONFIRMED envelope. Registers a periodic status push so the pod ORIGINATES + /// status-response frames on a schedule (caught by the unsolicited listener), with fault/alert + /// flags riding inside — "register once, don't poll". Called post-session-establishment while + /// holding podStateLock. Non-fatal: a rejection is logged so we can iterate the envelope. + /// + /// The exact wire form is unconfirmed. Current guesses (each a labeled knob to iterate from + /// field logs): command `SN0.0=,GN0.0` (feature "N0", attr "0", ASCII decimal + /// seconds), response prefix `N0.0=`. If REJECTED, try: a different feature/attr split, binary + /// vs ASCII seconds, or the standard `S0.0=` envelope. + private func configurePeriodicStatus() { + guard PeripheralManager.unsolicitedFaultListenerEnabled else { return } + guard let manager = manager, podState != nil else { return } + + let intervalSeconds = 60 // GUESS: push cadence + + let transport = BlePodMessageTransport(manager: manager, myId: myId, podId: podId, state: podState!.bleMessageTransportState, signingKey: podState?.signingKey) + transport.messageLogger = messageLogger + defer { + // Persist the sequence advance from this command so normal comms stay in sync. + podState!.bleMessageTransportState = BleMessageTransportState(ck: transport.ck, noncePrefix: transport.noncePrefix, msgSeq: transport.msgSeq, nonceSeq: transport.nonceSeq, messageNumber: transport.messageNumber) + } + + let feature = "N0", attribute = "0" + let payload = O5AidCommands.setGetPayload(feature: feature, attribute: attribute, data: String(intervalSeconds)) + let respPrefix = O5AidCommands.responsePrefix(feature: feature, attribute: attribute) + log.default("[periodic] register attempt: S%{public}@.%{public}@=%{public}d ascii=%{public}@ hex=%{public}@ respPrefix=%{public}@", + feature, attribute, intervalSeconds, String(data: payload, encoding: .utf8) ?? "?", payload.hexadecimalString, respPrefix) + do { + let response = try transport.sendO5AidCommand(payload, responsePrefix: respPrefix) + log.default("[periodic] register ACCEPTED. response ascii=%{public}@ hex=%{public}@", + String(data: response, encoding: .utf8) ?? "?", response.hexadecimalString) + } catch { + log.error("[periodic] register REJECTED/failed: %{public}@ — envelope guess likely wrong; iterate feature/attr/encoding.", String(describing: error)) + } + } + // MARK: - O5 Specific AID Setup commands /// Sends the O5-specific AID setup commands between GetStatus and SetupPod. @@ -741,6 +779,7 @@ extension BlePodComms: PeripheralManagerDelegate { try manager.enableNotifications() // Seemingly this cannot be done before the hello command, or the pod disconnects try establishNewSession() needsSessionEstablishment = false + configurePeriodicStatus() // PROTOTYPE: arm the pod to originate periodic status pushes manager.unsolicitedListenerArmed = true // encrypted session ready; safe to observe pod-initiated transfers delegate?.podCommsDidEstablishSession(self) } catch { @@ -791,13 +830,21 @@ extension BlePodComms { guard seq >= 0 else { continue } do { let decrypted = try enDecrypt.decrypt(packet, seq) - log.default("[unsolicited] DECRYPT OK at nonceSeq=%{public}d (delta=%{public}d). decryptedPayloadLen=%{public}d decryptedPayload=%{public}@", - seq, delta, decrypted.payload.count, decrypted.payload.hexadecimalString) - // STAGE 1 ENDS HERE — log only. We deliberately do NOT advance live - // nonceSeq/msgSeq/messageNumber and do NOT route the alert, because the - // correct advancement (and whether pods push these at all) is exactly what - // these field logs confirm. STAGE 2: advance bleMessageTransportState by the - // proven delta and feed `decrypted` through parseResponse -> fault handling. + log.default("[unsolicited] DECRYPT OK at nonceSeq=%{public}d (delta=%{public}d). decryptedPayloadLen=%{public}d decryptedPayload=%{public}@ decryptedASCII=%{public}@", + seq, delta, decrypted.payload.count, decrypted.payload.hexadecimalString, String(data: decrypted.payload, encoding: .utf8) ?? "") + // STAGE 2a: commit the nonce advance so the NEXT command stays in sync — the pod + // advanced its nonce for this push. Data-driven: use the offset that decrypted + // (expected +1). Runs on the serial sessionQueue, so no command overlaps this. + podStateLock.lock() + if var committed = podState?.bleMessageTransportState { + let before = committed.nonceSeq + committed.nonceSeq = seq + podState?.bleMessageTransportState = committed + log.default("[unsolicited] committed nonceSeq %{public}d -> %{public}d (delta=%{public}d)", before, seq, delta) + } + podStateLock.unlock() + // STAGE 2b (TODO once decrypt is confirmed in the field): parse `decrypted` as a + // status/DetailedStatus response and route a fault via notifyPodFault. return } catch { log.debug("[unsolicited] decrypt miss at nonceSeq=%{public}d (delta=%{public}d): %{public}@", seq, delta, String(describing: error)) From e01a0dc54cd2e150266082592e3a0127c21aed7d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 1 Jul 2026 09:17:29 -0500 Subject: [PATCH 05/92] Gate periodic registration to a healthy, set-up, non-faulted pod Field log: SN0.0=60,GN0.0 (534e302e...) was being sent on every reconnect during a faulted/incomplete pairing, feeding a connect -> register -> disconnect loop on a screaming pod. configurePeriodicStatus now no-ops unless podState.isSetupComplete and podState.fault == nil, so it only fires on a healthy established pod (and gives a clean registration test there). --- OmnipodKit/Bluetooth/BlePodComms.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index 990ae64..9582f1a 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -360,6 +360,17 @@ class BlePodComms: PodComms { private func configurePeriodicStatus() { guard PeripheralManager.unsolicitedFaultListenerEnabled else { return } guard let manager = manager, podState != nil else { return } + // Only on a healthy, fully-set-up pod. Never during pairing/activation, and never + // on a faulted pod — otherwise this fires on every reconnect of a screaming pod and + // adds SN0.0= writes to a connect/disconnect loop. + guard podState?.isSetupComplete == true else { + log.default("[periodic] skip registration: pod setup not complete") + return + } + guard podState?.fault == nil else { + log.default("[periodic] skip registration: pod is faulted") + return + } let intervalSeconds = 60 // GUESS: push cadence From feef537eae4800224ce18f512156c2374b6bf0a1 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 1 Jul 2026 15:30:30 -0500 Subject: [PATCH 06/92] Instrument periodic-status registration + push for a conclusive field run - sendO5AidCommand: unconditional 'O5 AID RawResp' log of the pod's full decrypted response (hex + ascii + type/seq) BEFORE the prefix check throws it away. On a rejected registration this is the datum needed to iterate the SN0.0= envelope. - configurePeriodicStatus: log pre/post transport state (nonceSeq/msgSeq/messageNumber/ eapSeq) so we can confirm the exchange advanced sequences correctly and stayed in sync. - Unsolicited push handler: best-effort Message decode of a decrypted push so a real fault frame shows its block structure (StatusResponse/DetailedStatus/PodInfoResponse), not raw hex. Non-fatal. Pod status flags around the registration are captured by the post-connect getStatus that already runs right after. No behavior change beyond logging. --- OmnipodKit/Bluetooth/BleMessageTransport.swift | 6 ++++++ OmnipodKit/Bluetooth/BlePodComms.swift | 17 ++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/OmnipodKit/Bluetooth/BleMessageTransport.swift b/OmnipodKit/Bluetooth/BleMessageTransport.swift index 2f5f1f3..a6c9471 100644 --- a/OmnipodKit/Bluetooth/BleMessageTransport.swift +++ b/OmnipodKit/Bluetooth/BleMessageTransport.swift @@ -562,6 +562,12 @@ class BlePodMessageTransport: MessageTransport { incrementNonceSeq() let decrypted = try enDecrypt.decrypt(readMessage, nonceSeq) + // Unconditional raw-response capture (before the prefix check throws it away). On a + // "rejected" registration this is THE datum we need to iterate the envelope. + log.default("O5 AID RawResp: type=%{public}@ seq=%{public}d len=%{public}d hex=%{public}@ ascii=%{public}@ expectPrefix=%{public}@", + String(describing: decrypted.type), decrypted.sequenceNumber, decrypted.payload.count, + decrypted.payload.hexadecimalString, String(data: decrypted.payload, encoding: .utf8) ?? "", responsePrefix) + // Extract response data using the caller-provided response prefix. // AID responses use plain ASCII key=value format (no SLPE length prefix), // so we just strip the prefix and return everything after it. diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index 9582f1a..59d4fbf 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -384,6 +384,8 @@ class BlePodComms: PodComms { let feature = "N0", attribute = "0" let payload = O5AidCommands.setGetPayload(feature: feature, attribute: attribute, data: String(intervalSeconds)) let respPrefix = O5AidCommands.responsePrefix(feature: feature, attribute: attribute) + log.default("[periodic] pre-register transport state: nonceSeq=%{public}d msgSeq=%{public}d messageNumber=%{public}d eapSeq=%{public}d bleId=%{public}@", + transport.nonceSeq, transport.msgSeq, transport.messageNumber, transport.eapSeq, podState?.bleIdentifier ?? "?") log.default("[periodic] register attempt: S%{public}@.%{public}@=%{public}d ascii=%{public}@ hex=%{public}@ respPrefix=%{public}@", feature, attribute, intervalSeconds, String(data: payload, encoding: .utf8) ?? "?", payload.hexadecimalString, respPrefix) do { @@ -391,8 +393,10 @@ class BlePodComms: PodComms { log.default("[periodic] register ACCEPTED. response ascii=%{public}@ hex=%{public}@", String(data: response, encoding: .utf8) ?? "?", response.hexadecimalString) } catch { - log.error("[periodic] register REJECTED/failed: %{public}@ — envelope guess likely wrong; iterate feature/attr/encoding.", String(describing: error)) + log.error("[periodic] register REJECTED/failed: %{public}@ — see [O5 AID RawResp] above for the pod's actual bytes; iterate feature/attr/encoding.", String(describing: error)) } + log.default("[periodic] post-register transport state: nonceSeq=%{public}d msgSeq=%{public}d messageNumber=%{public}d (a healthy exchange advances msgSeq+2, nonceSeq+3)", + transport.nonceSeq, transport.msgSeq, transport.messageNumber) } // MARK: - O5 Specific AID Setup commands @@ -843,6 +847,17 @@ extension BlePodComms { let decrypted = try enDecrypt.decrypt(packet, seq) log.default("[unsolicited] DECRYPT OK at nonceSeq=%{public}d (delta=%{public}d). decryptedPayloadLen=%{public}d decryptedPayload=%{public}@ decryptedASCII=%{public}@", seq, delta, decrypted.payload.count, decrypted.payload.hexadecimalString, String(data: decrypted.payload, encoding: .utf8) ?? "") + // Best-effort decode so a real fault push shows its structure (StatusResponse / + // DetailedStatus / PodInfoResponse), not just hex. Non-fatal; raw bytes logged above. + if let message = try? Message(encodedData: decrypted.payload, checkCRC: manager.podType.isO5) { + log.default("[unsolicited] decoded blocks=[%{public}@]", + message.messageBlocks.map { String(describing: $0.blockType) }.joined(separator: ", ")) + for block in message.messageBlocks { + log.default("[unsolicited] block: %{public}@", String(describing: block)) + } + } else { + log.default("[unsolicited] payload did not parse as a pod Message (may be an AID/text frame or partial)") + } // STAGE 2a: commit the nonce advance so the NEXT command stays in sync — the pod // advanced its nonce for this push. Data-driven: use the offset that decrypted // (expected +1). Runs on the serial sessionQueue, so no command overlaps this. From 03fab7d4792808950c75fe2d152baae1d8b30ee2 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 1 Jul 2026 15:39:11 -0500 Subject: [PATCH 07/92] =?UTF-8?q?Disable=20SN0.0=3D=20registration=20?= =?UTF-8?q?=E2=80=94=20it=20disconnect-loops=20a=20live=20pod?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field result: SN0.0=60 is not a recognized pod command. The pod returns no response (emptyValue), which OmnipodKit treats as an unresponsive pod and disconnects. On an active pod this loops: reconnect -> negotiate -> SN0.0= -> emptyValue -> disconnect -> reconnect. Confirmed twice (15:34, 15:37) on a healthy in-use pod. Comment out the configurePeriodicStatus() call. The passive listener stays (it only observes and never disconnects), but without a correct registration command the pod does not push, so this is inert until we have real protocol intel. Do not send blind command guesses to a live pod. --- OmnipodKit/Bluetooth/BlePodComms.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index 59d4fbf..f4028b2 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -794,7 +794,12 @@ extension BlePodComms: PeripheralManagerDelegate { try manager.enableNotifications() // Seemingly this cannot be done before the hello command, or the pod disconnects try establishNewSession() needsSessionEstablishment = false - configurePeriodicStatus() // PROTOTYPE: arm the pod to originate periodic status pushes + // DISABLED: SN0.0=60 is not a recognized pod command — the pod returns no + // response (emptyValue), which OmnipodKit treats as an unresponsive pod and + // DISCONNECTS. On an active pod this loops (reconnect -> SN0.0= -> drop). Do not + // send blind command guesses to a live pod. configurePeriodicStatus() retained + // for reference but must not run until we have the correct registration command. + // configurePeriodicStatus() manager.unsolicitedListenerArmed = true // encrypted session ready; safe to observe pod-initiated transfers delegate?.podCommsDidEstablishSession(self) } catch { From 2df6c00b8d9bcc84aacec58d7893a86545d606fb Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 1 Jul 2026 15:58:04 -0500 Subject: [PATCH 08/92] Send SN0.0= as a standard SLPE command, not plain-ASCII AID (+ non-disconnecting read) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the earlier disconnect loop: SN0.0= is structurally a STANDARD S...= command, which uses StringLengthPrefixEncoding (2-byte big-endian length prefix before the value) for both O5 and DASH. It was built via O5AidCommands.setGetPayload (plain ASCII, no length prefix) and sent via sendO5AidCommand. The pod got a frame whose value wasn't length-delimited, could not parse the envelope, and stayed silent (emptyValue, never a NAK/response) — the signature of an unparseable frame, not a rejected-but-understood command. Fix: - Build the payload with StringLengthPrefixEncoding.formatKeys(keys:["SN0.0=", ",GN0.0"], payloads:["60", ""]) -> 534e302e303d000236302c474e302e30 (16B, vs the 14B plain form). - New BlePodMessageTransport.sendSlpeGetSetCommand: SLPE format on the way out, parseKeys on the response (the response is length-prefixed too, so sendO5AidCommand's plain prefix-strip cannot handle it), and a NON-disconnecting read so a still-wrong guess logs a failure instead of dropping a live pod. - Re-enable configurePeriodicStatus() with the corrected encoding. Instrumentation logs the wrapped request bytes, the raw SLPE response (SLPE RawResp), and pre/post transport sequences. --- .../Bluetooth/BleMessageTransport.swift | 63 +++++++++++++++++++ OmnipodKit/Bluetooth/BlePodComms.swift | 37 ++++++----- 2 files changed, 84 insertions(+), 16 deletions(-) diff --git a/OmnipodKit/Bluetooth/BleMessageTransport.swift b/OmnipodKit/Bluetooth/BleMessageTransport.swift index a6c9471..e8a8516 100644 --- a/OmnipodKit/Bluetooth/BleMessageTransport.swift +++ b/OmnipodKit/Bluetooth/BleMessageTransport.swift @@ -596,6 +596,69 @@ class BlePodMessageTransport: MessageTransport { return responseData } + /// Send a STANDARD SLPE (2-byte length-prefixed) S…=/,G… command and parse the + /// length-prefixed response with parseKeys. This matches how standard S0.0=…,G0.0 commands + /// are framed (unlike sendO5AidCommand, which is plain-ASCII AID with a bare prefix-strip). + /// Uses a NON-disconnecting read so an unrecognized/unparseable command (silent pod) logs a + /// failure instead of dropping a live pod. + func sendSlpeGetSetCommand(keys: [String], payloads: [Data], responseKeys: [String]) throws -> [Data] { + guard let enDecrypt = self.enDecrypt else { + throw PodCommsError.podNotConnected + } + guard manager.peripheral.state == .connected else { + throw PodCommsError.podNotConnected + } + + let wrappedPayload = StringLengthPrefixEncoding.formatKeys(keys: keys, payloads: payloads) + + incrementMsgSeq() + let msg = MessagePacket( + type: MessageType.ENCRYPTED, + source: self.myId, + destination: self.podId, + payload: wrappedPayload, + sequenceNumber: UInt8(msgSeq), + eqos: 1 + ) + incrementNonceSeq() + let encrypted = try enDecrypt.encrypt(msg, nonceSeq) + + log.default("SLPE Send (%{public}d bytes): %{public}@", wrappedPayload.count, wrappedPayload.hexadecimalString) + messageLogger?.didSend(wrappedPayload) + + let writeResult = manager.sendMessagePacket(encrypted) + switch writeResult { + case .sentWithAcknowledgment: + break + case .sentWithError(let error): + throw PodCommsError.commsError(error: error) + case .unsentWithError(let error): + throw PodCommsError.commsError(error: error) + } + + // NON-disconnecting read: a silent pod (unparseable/unknown command) must NOT drop the link. + guard let readMessage = try manager.readMessagePacket(disconnectOnUnresponsivePod: false) else { + throw PodProtocolError.messageIOException("No SLPE response (pod silent)") + } + + incrementNonceSeq() + let decrypted = try enDecrypt.decrypt(readMessage, nonceSeq) + + log.default("SLPE RawResp: type=%{public}@ seq=%{public}d len=%{public}d hex=%{public}@ ascii=%{public}@ respKeys=%{public}@", + String(describing: decrypted.type), decrypted.sequenceNumber, decrypted.payload.count, + decrypted.payload.hexadecimalString, String(data: decrypted.payload, encoding: .utf8) ?? "", responseKeys.joined()) + + // ACK the response before parsing, so a parse failure still leaves the pod acknowledged. + incrementMsgSeq() + incrementNonceSeq() + let ack = try getAck(response: decrypted) + if case .sentWithAcknowledgment = manager.sendMessagePacket(ack) {} else { + log.error("SLPE: could not send ACK for response") + } + + return try StringLengthPrefixEncoding.parseKeys(responseKeys, decrypted.payload) + } + func assertOnSessionQueue() { dispatchPrecondition(condition: .onQueue(manager.queue)) } diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index f4028b2..862e9bc 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -381,21 +381,28 @@ class BlePodComms: PodComms { podState!.bleMessageTransportState = BleMessageTransportState(ck: transport.ck, noncePrefix: transport.noncePrefix, msgSeq: transport.msgSeq, nonceSeq: transport.nonceSeq, messageNumber: transport.messageNumber) } - let feature = "N0", attribute = "0" - let payload = O5AidCommands.setGetPayload(feature: feature, attribute: attribute, data: String(intervalSeconds)) - let respPrefix = O5AidCommands.responsePrefix(feature: feature, attribute: attribute) + // SN0.0= is structurally a STANDARD S…= command, so it uses SLPE (2-byte length-prefixed) + // encoding via formatKeys — NOT the plain-ASCII AID path (the earlier bug). The value "60" + // is length-prefixed under the "SN0.0=" key; the trailing get-key ",GN0.0" has no value. + // Response is the length-prefixed "N0.0=" key, parsed with parseKeys. The read is + // non-disconnecting, so a still-wrong guess logs a failure instead of looping a live pod. + let keys = ["SN0.0=", ",GN0.0"] + let payloads = [Data(String(intervalSeconds).utf8), Data()] + let respKeys = ["N0.0="] + let wrapped = StringLengthPrefixEncoding.formatKeys(keys: keys, payloads: payloads) log.default("[periodic] pre-register transport state: nonceSeq=%{public}d msgSeq=%{public}d messageNumber=%{public}d eapSeq=%{public}d bleId=%{public}@", transport.nonceSeq, transport.msgSeq, transport.messageNumber, transport.eapSeq, podState?.bleIdentifier ?? "?") - log.default("[periodic] register attempt: S%{public}@.%{public}@=%{public}d ascii=%{public}@ hex=%{public}@ respPrefix=%{public}@", - feature, attribute, intervalSeconds, String(data: payload, encoding: .utf8) ?? "?", payload.hexadecimalString, respPrefix) + log.default("[periodic] register attempt (SLPE): keys=%{public}@ seconds=%{public}d wrappedHex=%{public}@ respKeys=%{public}@", + keys.joined(), intervalSeconds, wrapped.hexadecimalString, respKeys.joined()) do { - let response = try transport.sendO5AidCommand(payload, responsePrefix: respPrefix) - log.default("[periodic] register ACCEPTED. response ascii=%{public}@ hex=%{public}@", - String(data: response, encoding: .utf8) ?? "?", response.hexadecimalString) + let values = try transport.sendSlpeGetSetCommand(keys: keys, payloads: payloads, responseKeys: respKeys) + let v = values.first ?? Data() + log.default("[periodic] register ACCEPTED (SLPE). value ascii=%{public}@ hex=%{public}@", + String(data: v, encoding: .utf8) ?? "?", v.hexadecimalString) } catch { - log.error("[periodic] register REJECTED/failed: %{public}@ — see [O5 AID RawResp] above for the pod's actual bytes; iterate feature/attr/encoding.", String(describing: error)) + log.error("[periodic] register failed: %{public}@ — non-disconnecting (pod NOT dropped); see [SLPE RawResp] above if the pod responded.", String(describing: error)) } - log.default("[periodic] post-register transport state: nonceSeq=%{public}d msgSeq=%{public}d messageNumber=%{public}d (a healthy exchange advances msgSeq+2, nonceSeq+3)", + log.default("[periodic] post-register transport state: nonceSeq=%{public}d msgSeq=%{public}d messageNumber=%{public}d (a full exchange advances msgSeq+2, nonceSeq+3)", transport.nonceSeq, transport.msgSeq, transport.messageNumber) } @@ -794,12 +801,10 @@ extension BlePodComms: PeripheralManagerDelegate { try manager.enableNotifications() // Seemingly this cannot be done before the hello command, or the pod disconnects try establishNewSession() needsSessionEstablishment = false - // DISABLED: SN0.0=60 is not a recognized pod command — the pod returns no - // response (emptyValue), which OmnipodKit treats as an unresponsive pod and - // DISCONNECTS. On an active pod this loops (reconnect -> SN0.0= -> drop). Do not - // send blind command guesses to a live pod. configurePeriodicStatus() retained - // for reference but must not run until we have the correct registration command. - // configurePeriodicStatus() + // Re-enabled with the corrected SLPE encoding + a non-disconnecting read. The + // earlier plain-ASCII attempt sent an unparseable frame → pod silent → disconnect + // loop. If SLPE is still wrong the read no longer drops the pod (logs + continues). + configurePeriodicStatus() manager.unsolicitedListenerArmed = true // encrypted session ready; safe to observe pod-initiated transfers delegate?.podCommsDidEstablishSession(self) } catch { From 13670ed68e978ecf2ab098788712eba0fc54edc5 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 1 Jul 2026 16:15:07 -0500 Subject: [PATCH 09/92] Instrument BluetoothManager instance lifecycle to pin the multi-central issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Console shows 4x centralManagerDidUpdateState / 'Recovered peripheral from autoConnectIDs' in one process — suspected multiple leaked CBCentralManagers under the shared com.OmnipodKit restore identifier (the likely root of the didConnect-never-fires pairing failure). Add a per-instance ID (short UUID) to BluetoothManager, log INIT (with the creation call stack) and DEINIT, and tag centralManagerDidUpdateState + the autoConnectIDs recovery with it. The next capture then answers definitively: N INITs with no DEINITs = leaked centrals, and the INIT call stack shows the creation site (podType setter / restore path / plugin lookup). --- OmnipodKit/Bluetooth/BluetoothManager.swift | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 894cfea..e95f3f8 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -140,15 +140,28 @@ class BluetoothManager: NSObject { // MARK: - Synchronization private let managerQueue = DispatchQueue(label: "com.OmnipodKit.bluetoothManagerQueue", qos: .unspecified) + /// Per-instance ID so multiple centrals under the shared "com.OmnipodKit" restore identifier + /// can be told apart in the log. INIT/DEINIT + the central callbacks are all tagged with it: + /// N distinct INITs with no matching DEINITs = leaked centrals (the suspected pairing-bug root). + let instanceID = String(UUID().uuidString.prefix(8)) + init(podType: PodType) { self.podType = podType super.init() + log.default("BluetoothManager #%{public}@ INIT (podType=%{public}@). Created from:\n%{public}@", + instanceID, String(describing: podType), + Thread.callStackSymbols.dropFirst().prefix(12).joined(separator: "\n")) + managerQueue.sync { self.manager = CBCentralManager(delegate: self, queue: managerQueue, options: [CBCentralManagerOptionRestoreIdentifierKey: "com.OmnipodKit"]) } } - + + deinit { + log.default("BluetoothManager #%{public}@ DEINIT", instanceID) + } + @discardableResult private func addPeripheral(_ peripheral: CBPeripheral, podAdvertisement: PodAdvertisement?) -> Omni { dispatchPrecondition(condition: .onQueue(managerQueue)) @@ -334,7 +347,7 @@ extension BluetoothManager: CBCentralManagerDelegate { func centralManagerDidUpdateState(_ central: CBCentralManager) { dispatchPrecondition(condition: .onQueue(managerQueue)) - log.default("%{public}@: %{public}@", #function, String(describing: central.state.rawValue)) + log.default("[#%{public}@] %{public}@: %{public}@", instanceID, #function, String(describing: central.state.rawValue)) if case .poweredOn = central.state { // bluetooth may have reset; update peripheral references @@ -353,7 +366,7 @@ extension BluetoothManager: CBCentralManagerDelegate { if let uuid = UUID(uuidString: uuidString), let peripheral = central.retrievePeripherals(withIdentifiers: [uuid]).first { - log.default("Recovered peripheral from autoConnectIDs: %{public}@", uuidString) + log.default("[#%{public}@] Recovered peripheral from autoConnectIDs: %{public}@", instanceID, uuidString) addPeripheral(peripheral, podAdvertisement: nil) central.connect(peripheral, options: nil) } From cb119e820394e6d3367ce91c5c6af3d9b0fe3f89 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 1 Jul 2026 16:24:04 -0500 Subject: [PATCH 10/92] Add per-instance ID to OmnipodKitPlugin to settle real-vs-duplicated instantiation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BluetoothManager's instance ID proved there's ONE central (same ID repeated = capture duplication). But the plugin log had no ID, so 5x 'Instantiated' could be 5 real instances or 1 duplicated. Loop's PluginManager.getPumpManagerTypeByIdentifier does principalClass.init() per type lookup (pump AND cgm), so multiple real instances are plausible (benign — init only logs, no central/state). Log a per-instance short UUID + object address so the next capture is unambiguous: distinct IDs = real instances; one ID repeated = capture dup. --- OmnipodKitPlugin/OmnipodKitPlugin.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/OmnipodKitPlugin/OmnipodKitPlugin.swift b/OmnipodKitPlugin/OmnipodKitPlugin.swift index 2ce220f..af2ce35 100644 --- a/OmnipodKitPlugin/OmnipodKitPlugin.swift +++ b/OmnipodKitPlugin/OmnipodKitPlugin.swift @@ -22,8 +22,13 @@ class OmnipodKitPlugin: NSObject, PumpManagerUIPlugin { return nil } + // Per-instance ID: distinct IDs (and object addresses) across the log = real multiple + // instantiations (Loop's plugin lookup does principalClass.init() per type query); one ID + // repeated = capture duplication of a single instance. + private let instanceID = String(UUID().uuidString.prefix(8)) + override init() { super.init() - log.default("OmnipodKitPlugin Instantiated") + log.default("OmnipodKitPlugin #%{public}@ Instantiated (%{public}@)", instanceID, self.debugDescription) } } From b613284422b55a524e6ed54eb62422b97119effd Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 1 Jul 2026 17:38:26 -0500 Subject: [PATCH 11/92] Periodic registration: fire-and-forget (no synchronous read) + log all connects/disconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registration is fire-and-forget — the pod sends no synchronous reply, so reading for N0.0= always timed out with emptyValue (a false negative), never the actual result. Success is the write being ACK'd; the real confirmation is the pod's first unsolicited PUSH ~interval later, caught by the listener. - New sendSlpeCommandFireAndForget: send SLPE command, treat sentWithAcknowledgment as success, no readMessagePacket. Advances msgSeq+1/nonceSeq+1 for the send (pod does the same on receipt). - configurePeriodicStatus: use fire-and-forget, drop interval 60s -> 10s so a push arrives quickly. - Log all central connect/disconnect/failToConnect events, tagged with the BluetoothManager instance ID + peripheral + reason/willReconnect. --- .../Bluetooth/BleMessageTransport.swift | 40 +++++++++++++++++++ OmnipodKit/Bluetooth/BlePodComms.swift | 31 +++++++------- OmnipodKit/Bluetooth/BluetoothManager.swift | 10 +++-- 3 files changed, 62 insertions(+), 19 deletions(-) diff --git a/OmnipodKit/Bluetooth/BleMessageTransport.swift b/OmnipodKit/Bluetooth/BleMessageTransport.swift index e8a8516..377deaa 100644 --- a/OmnipodKit/Bluetooth/BleMessageTransport.swift +++ b/OmnipodKit/Bluetooth/BleMessageTransport.swift @@ -659,6 +659,46 @@ class BlePodMessageTransport: MessageTransport { return try StringLengthPrefixEncoding.parseKeys(responseKeys, decrypted.payload) } + /// Send a STANDARD SLPE command with NO synchronous read — for fire-and-forget commands like + /// periodic-status registration, where the pod sends no immediate reply and success is the + /// write being ACK'd (the real confirmation arrives later as an unsolicited push). Advances + /// msgSeq+1/nonceSeq+1 for the send only; the pod advances the same on receipt, so comms stay + /// in sync. Throws only if the write itself is not acknowledged. + func sendSlpeCommandFireAndForget(keys: [String], payloads: [Data]) throws { + guard let enDecrypt = self.enDecrypt else { + throw PodCommsError.podNotConnected + } + guard manager.peripheral.state == .connected else { + throw PodCommsError.podNotConnected + } + + let wrappedPayload = StringLengthPrefixEncoding.formatKeys(keys: keys, payloads: payloads) + + incrementMsgSeq() + let msg = MessagePacket( + type: MessageType.ENCRYPTED, + source: self.myId, + destination: self.podId, + payload: wrappedPayload, + sequenceNumber: UInt8(msgSeq), + eqos: 1 + ) + incrementNonceSeq() + let encrypted = try enDecrypt.encrypt(msg, nonceSeq) + + log.default("SLPE Send (fire-and-forget, %{public}d bytes): %{public}@", wrappedPayload.count, wrappedPayload.hexadecimalString) + messageLogger?.didSend(wrappedPayload) + + switch manager.sendMessagePacket(encrypted) { + case .sentWithAcknowledgment: + return + case .sentWithError(let error): + throw PodCommsError.commsError(error: error) + case .unsentWithError(let error): + throw PodCommsError.commsError(error: error) + } + } + func assertOnSessionQueue() { dispatchPrecondition(condition: .onQueue(manager.queue)) } diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index 862e9bc..6ec6cf6 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -372,37 +372,36 @@ class BlePodComms: PodComms { return } - let intervalSeconds = 60 // GUESS: push cadence + let intervalSeconds = 10 // ~10s so the first push arrives quickly while testing (was 60) let transport = BlePodMessageTransport(manager: manager, myId: myId, podId: podId, state: podState!.bleMessageTransportState, signingKey: podState?.signingKey) transport.messageLogger = messageLogger defer { - // Persist the sequence advance from this command so normal comms stay in sync. + // Persist the sequence advance from the send so normal comms stay in sync. podState!.bleMessageTransportState = BleMessageTransportState(ck: transport.ck, noncePrefix: transport.noncePrefix, msgSeq: transport.msgSeq, nonceSeq: transport.nonceSeq, messageNumber: transport.messageNumber) } - // SN0.0= is structurally a STANDARD S…= command, so it uses SLPE (2-byte length-prefixed) - // encoding via formatKeys — NOT the plain-ASCII AID path (the earlier bug). The value "60" - // is length-prefixed under the "SN0.0=" key; the trailing get-key ",GN0.0" has no value. - // Response is the length-prefixed "N0.0=" key, parsed with parseKeys. The read is - // non-disconnecting, so a still-wrong guess logs a failure instead of looping a live pod. + // SN0.0= is a STANDARD S…= command (SLPE, 2-byte length-prefixed): the interval value is + // length-prefixed under "SN0.0=", the trailing get-key ",GN0.0" is empty. Registration is + // FIRE-AND-FORGET — the pod sends NO synchronous reply, so we do not read one (reading always + // timed out with emptyValue, a false negative). Success = the write is ACK'd; the real + // confirmation is the pod's first unsolicited PUSH ~intervalSeconds later, caught by the + // unsolicited listener. let keys = ["SN0.0=", ",GN0.0"] let payloads = [Data(String(intervalSeconds).utf8), Data()] - let respKeys = ["N0.0="] let wrapped = StringLengthPrefixEncoding.formatKeys(keys: keys, payloads: payloads) log.default("[periodic] pre-register transport state: nonceSeq=%{public}d msgSeq=%{public}d messageNumber=%{public}d eapSeq=%{public}d bleId=%{public}@", transport.nonceSeq, transport.msgSeq, transport.messageNumber, transport.eapSeq, podState?.bleIdentifier ?? "?") - log.default("[periodic] register attempt (SLPE): keys=%{public}@ seconds=%{public}d wrappedHex=%{public}@ respKeys=%{public}@", - keys.joined(), intervalSeconds, wrapped.hexadecimalString, respKeys.joined()) + log.default("[periodic] register (fire-and-forget SLPE): keys=%{public}@ seconds=%{public}d wrappedHex=%{public}@ — success=write-ACK; watch [unsolicited] for a push in ~%{public}ds", + keys.joined(), intervalSeconds, wrapped.hexadecimalString, intervalSeconds) do { - let values = try transport.sendSlpeGetSetCommand(keys: keys, payloads: payloads, responseKeys: respKeys) - let v = values.first ?? Data() - log.default("[periodic] register ACCEPTED (SLPE). value ascii=%{public}@ hex=%{public}@", - String(data: v, encoding: .utf8) ?? "?", v.hexadecimalString) + try transport.sendSlpeCommandFireAndForget(keys: keys, payloads: payloads) + log.default("[periodic] register write ACK'd — pod received SN0.0=%{public}d. Expecting an unsolicited push in ~%{public}ds (the push, not a reply, is the success signal).", + intervalSeconds, intervalSeconds) } catch { - log.error("[periodic] register failed: %{public}@ — non-disconnecting (pod NOT dropped); see [SLPE RawResp] above if the pod responded.", String(describing: error)) + log.error("[periodic] register send NOT ACK'd: %{public}@", String(describing: error)) } - log.default("[periodic] post-register transport state: nonceSeq=%{public}d msgSeq=%{public}d messageNumber=%{public}d (a full exchange advances msgSeq+2, nonceSeq+3)", + log.default("[periodic] post-register transport state: nonceSeq=%{public}d msgSeq=%{public}d messageNumber=%{public}d", transport.nonceSeq, transport.msgSeq, transport.messageNumber) } diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index e95f3f8..3cdcbf2 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -442,8 +442,9 @@ extension BluetoothManager: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { dispatchPrecondition(condition: .onQueue(managerQueue)) - log.debug("%{public}@: %{public}@", #function, peripheral) - + log.default("[#%{public}@] CONNECTED: %{public}@ (known device: %{public}@)", instanceID, peripheral, + String(describing: devices.contains { $0.manager.peripheral.identifier == peripheral.identifier })) + // Proxy connection events to peripheral manager for device in devices where device.manager.peripheral.identifier == peripheral.identifier { device.manager.centralManager(central, didConnect: peripheral) @@ -457,6 +458,9 @@ extension BluetoothManager: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { dispatchPrecondition(condition: .onQueue(managerQueue)) + log.default("[#%{public}@] DISCONNECTED: %{public}@ error=%{public}@ willReconnect=%{public}@", instanceID, peripheral, + String(describing: error), String(describing: autoConnectIDs.contains(peripheral.identifier.uuidString))) + // Proxy disconnection events to peripheral manager for device in devices where device.manager.peripheral.identifier == peripheral.identifier { device.manager.centralManager(central, didDisconnect: peripheral, error: error) @@ -473,7 +477,7 @@ extension BluetoothManager: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { dispatchPrecondition(condition: .onQueue(managerQueue)) - log.error("%{public}@: %{public}@", #function, String(describing: error)) + log.error("[#%{public}@] FAILED TO CONNECT: %{public}@ error=%{public}@", instanceID, peripheral, String(describing: error)) connectionDelegate?.omnipodPeripheralDidFailToConnect(peripheral: peripheral, error: error) From 7804b39dfa5816f5fcb33381681014b18014d718 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 2 Jul 2026 10:21:28 -0500 Subject: [PATCH 12/92] Advertisement monitor: continuous duplicate scan, full [ADV] logging, connect latency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toward the 'faults via advertisement, connect on demand' model. Field data (fire-and-forget build) showed a reconnect after ~3min idle takes ~28s and fails the first command (podNotConnected) — because the central is NOT scanning during idle (bluetoothd isScanning: False), so reconnect waits on iOS background scan + the pod's advertisement cadence. - advertisementMonitorEnabled flag (default ON for field test). - startScanning uses allowDuplicates when the flag is on; scanning is kept alive continuously (not stopped once autoConnect devices are known) so we observe advertisements while disconnected. - didDiscover logs a full [ADV] dump for pod-identified peripherals: RSSI, state, connectable, service-UUID list (which changed between scans early on), manufacturer data, service data. - timedConnect stamps every connect; didConnect logs the measured connect latency. Note: iOS does not deliver advertisements for a CONNECTED peripheral, so [ADV] frames only appear while disconnected — the next step is connect-on-demand + idle-disconnect so the pod is disconnected (and thus advertising/observable) between commands. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 83 ++++++++++++++++----- 1 file changed, 66 insertions(+), 17 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 3cdcbf2..f617269 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -145,6 +145,24 @@ class BluetoothManager: NSObject { /// N distinct INITs with no matching DEINITs = leaked centrals (the suspected pairing-bug root). let instanceID = String(UUID().uuidString.prefix(8)) + /// Field-test flag: keep scanning continuously (allowDuplicates) and log every pod advertisement, + /// to test the "stay scanning, connect on demand, faults signalled via advertisement" model. + static var advertisementMonitorEnabled: Bool { + UserDefaults.standard.object(forKey: "OmnipodKit.advertisementMonitorEnabled") as? Bool ?? true + } + + /// Connect-request timestamps (by peripheral UUID) for measuring connect latency in didConnect. + private var connectRequestedAt: [String: Date] = [:] + + /// Stamp the connect time and issue the connect, so didConnect can report the latency. + private func timedConnect(_ peripheral: CBPeripheral) { + if connectRequestedAt[peripheral.identifier.uuidString] == nil { + connectRequestedAt[peripheral.identifier.uuidString] = Date() + } + let cm: CBCentralManager = manager + cm.connect(peripheral, options: nil) + } + init(podType: PodType) { self.podType = podType super.init() @@ -222,7 +240,7 @@ class BluetoothManager: NSObject { { self.log.default("connectToDevice: retrieved peripheral %{public}@ via retrievePeripherals", uuidString) self.addPeripheral(peripheral, podAdvertisement: nil) - self.manager.connect(peripheral, options: nil) + self.timedConnect(peripheral) } } } @@ -240,7 +258,7 @@ class BluetoothManager: NSObject { } let device = addPeripheral(peripheral, podAdvertisement: nil) autoConnectIDs.insert(uuidString) - manager.connect(peripheral, options: nil) + timedConnect(peripheral) log.default("retrieveAndConnectKnownPod: initiating connection to %{public}@", peripheral) result = device } @@ -264,7 +282,7 @@ class BluetoothManager: NSObject { if autoConnectIDs.contains(peripheral.identifier.uuidString) { if peripheral.state == .disconnected || peripheral.state == .disconnecting { log.info("updateConnections: Connecting to peripheral: %{public}@", peripheral) - manager.connect(peripheral, options: nil) + timedConnect(peripheral) } } else { if peripheral.state == .connected || peripheral.state == .connecting { @@ -291,7 +309,7 @@ class BluetoothManager: NSObject { let peripheral = device.manager.peripheral if peripheral.state == .disconnected || peripheral.state == .disconnecting { log.info("discoverPods: Connecting to peripheral: %{public}@", peripheral) - manager.connect(peripheral, options: nil) + timedConnect(peripheral) } } startScanning() @@ -307,8 +325,14 @@ class BluetoothManager: NSObject { } else { serviceUUID = podType.blePodProfile.advertisementServiceUUID } - log.default("Start scanning for %{public}@", serviceUUID.uuidString) - manager.scanForPeripherals(withServices: [serviceUUID], options: nil) + // Monitor mode: allowDuplicates so we see the advertisement cadence (foreground only — + // iOS coalesces duplicates in the background). + let options: [String: Any] = BluetoothManager.advertisementMonitorEnabled + ? [CBCentralManagerScanOptionAllowDuplicatesKey: true] : [:] + log.default("Start scanning for %{public}@ (advertisementMonitor=%{public}@, allowDuplicates=%{public}@)", + serviceUUID.uuidString, String(describing: BluetoothManager.advertisementMonitorEnabled), + String(describing: options[CBCentralManagerScanOptionAllowDuplicatesKey] != nil)) + manager.scanForPeripherals(withServices: [serviceUUID], options: options) } private func stopScanning() { @@ -355,7 +379,7 @@ extension BluetoothManager: CBCentralManagerDelegate { if let newPeripheral = central.retrievePeripherals(withIdentifiers: [device.manager.peripheral.identifier]).first { log.debug("Re-connecting to known peripheral %{public}@", newPeripheral.identifier.uuidString) device.manager.peripheral = newPeripheral - central.connect(newPeripheral) + timedConnect(newPeripheral) } } @@ -368,13 +392,17 @@ extension BluetoothManager: CBCentralManagerDelegate { { log.default("[#%{public}@] Recovered peripheral from autoConnectIDs: %{public}@", instanceID, uuidString) addPeripheral(peripheral, podAdvertisement: nil) - central.connect(peripheral, options: nil) + timedConnect(peripheral) } } updateConnections() - if (discoveryModeEnabled || !hasDiscoveredAllAutoConnectDevices) && !manager.isScanning { + if BluetoothManager.advertisementMonitorEnabled { + // Monitor mode: keep scanning continuously so we observe pod advertisements, + // regardless of whether all autoConnect devices are known/connected. + if !manager.isScanning { startScanning() } + } else if (discoveryModeEnabled || !hasDiscoveredAllAutoConnectDevices) && !manager.isScanning { startScanning() } else if !discoveryModeEnabled && manager.isScanning { stopScanning() @@ -412,7 +440,21 @@ extension BluetoothManager: CBCentralManagerDelegate { log.debug("%{public}@: %{public}@, %{public}@", #function, peripheral, advertisementData) - if let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data { + // Full advertisement dump for pod-identified peripherals — to test the "faults via + // advertisement" model: capture every field (esp. the service-UUID list, which changed + // between scans early on) + RSSI + connectable + peripheral state, every time. + if BluetoothManager.advertisementMonitorEnabled, + autoConnectIDs.contains(peripheral.identifier.uuidString) || PodAdvertisement(advertisementData, podType: podType) != nil { + let svcUUIDs = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])?.map { $0.uuidString }.joined(separator: ",") ?? "-" + let mfg = (advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data)?.hexadecimalString ?? "-" + let svcData = (advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data])? + .map { "\($0.key.uuidString):\($0.value.hexadecimalString)" }.joined(separator: ",") ?? "-" + let connectable = advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber + let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "-" + log.default("[ADV] %{public}@ rssi=%{public}@ state=%{public}@ connectable=%{public}@ name=%{public}@ svcUUIDs=[%{public}@] mfg=%{public}@ svcData=%{public}@", + peripheral.identifier.uuidString, RSSI, String(describing: peripheral.state.rawValue), + String(describing: connectable), name, svcUUIDs, mfg, svcData) + } else if let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data { log.default("[SCAN] ManufacturerData: %{public}@ (%{public}d bytes)", mfgData.hexadecimalString, mfgData.count) } @@ -422,10 +464,10 @@ extension BluetoothManager: CBCentralManagerDelegate { if discoveryModeEnabled && peripheral.state == .disconnected && podAdvertisement.pairable { // Connect to any pairable device, during discovery log.default("Connecting to pairable device %{public} in discovery mode", peripheral) - manager.connect(peripheral, options: nil) + timedConnect(peripheral) } else if autoConnectIDs.contains(peripheral.identifier.uuidString) && peripheral.state == .disconnected { log.debug("Reonnecting to autoconnect device") - manager.connect(peripheral, options: nil) + timedConnect(peripheral) } else { log.info("Ignoring paired or unconnectable peripheral: %{public}@", peripheral) } @@ -433,7 +475,7 @@ extension BluetoothManager: CBCentralManagerDelegate { log.info("Ignoring peripheral with unexpected advertisement data: %{public}@", advertisementData) } - if !discoveryModeEnabled && central.isScanning && hasDiscoveredAllAutoConnectDevices { + if !BluetoothManager.advertisementMonitorEnabled && !discoveryModeEnabled && central.isScanning && hasDiscoveredAllAutoConnectDevices { log.debug("All peripherals discovered") stopScanning() } @@ -442,8 +484,15 @@ extension BluetoothManager: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { dispatchPrecondition(condition: .onQueue(managerQueue)) - log.default("[#%{public}@] CONNECTED: %{public}@ (known device: %{public}@)", instanceID, peripheral, - String(describing: devices.contains { $0.manager.peripheral.identifier == peripheral.identifier })) + if let requestedAt = connectRequestedAt.removeValue(forKey: peripheral.identifier.uuidString) { + let latency = String(format: "%.3f", Date().timeIntervalSince(requestedAt)) + log.default("[#%{public}@] CONNECTED: %{public}@ — connect latency %{public}@s (known device: %{public}@)", + instanceID, peripheral, latency, + String(describing: devices.contains { $0.manager.peripheral.identifier == peripheral.identifier })) + } else { + log.default("[#%{public}@] CONNECTED: %{public}@ — connect latency unknown (no request stamp) (known device: %{public}@)", + instanceID, peripheral, String(describing: devices.contains { $0.manager.peripheral.identifier == peripheral.identifier })) + } // Proxy connection events to peripheral manager for device in devices where device.manager.peripheral.identifier == peripheral.identifier { @@ -470,7 +519,7 @@ extension BluetoothManager: CBCentralManagerDelegate { if autoConnectIDs.contains(peripheral.identifier.uuidString) { log.debug("Reconnecting disconnected autoconnect peripheral") - central.connect(peripheral, options: nil) + timedConnect(peripheral) } } @@ -482,7 +531,7 @@ extension BluetoothManager: CBCentralManagerDelegate { connectionDelegate?.omnipodPeripheralDidFailToConnect(peripheral: peripheral, error: error) if autoConnectIDs.contains(peripheral.identifier.uuidString) { - central.connect(peripheral, options: nil) + timedConnect(peripheral) } } } From ae48d99ca7e62c0ebdc33bf6e57b3700d48ee264 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 2 Jul 2026 10:51:17 -0500 Subject: [PATCH 13/92] Connect-on-demand: 'normally disconnected' pump connectivity (field test) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toward the advertisement model: instead of holding the pod connected, leave it disconnected (and advertising/observable) between commands, connect on demand per session, disconnect when idle, and scan while disconnected. BluetoothManager (connectOnDemandEnabled flag, default ON): - New autoReconnect() wraps the keep-connected reconnect sites (didDisconnect, didDiscover autoconnect, updateConnections, poweredOn recovery, retrieveKnownPod, connectToDevice, failed connect retry) and NO-OPs when the flag is on. Explicit pairing/discovery connects are unchanged. PeripheralManager: - configureAndRun: in connect-on-demand mode, connect on demand for the session instead of the forceful reconnect (the heuristic that caused ~28s disconnect-then-wait stalls). - connectOnDemand(timeout:): issue a real connect() + wait for didConnect, logging measured latency. - scheduleIdleDisconnectIfNeeded: ~4s after a session with an empty queue, cancelPeripheralConnection. Continuous scanning (advertisementMonitor) + [ADV] logging now produce advertisement frames while disconnected. Caveat: idle-disconnect can churn a NEW pod's activation between steps — active pods are fine. Revert = clean rebuild with the flag off. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 40 ++++++++++++----- OmnipodKit/Bluetooth/PeripheralManager.swift | 45 +++++++++++++++++++- 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index f617269..61df5ef 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -151,6 +151,14 @@ class BluetoothManager: NSObject { UserDefaults.standard.object(forKey: "OmnipodKit.advertisementMonitorEnabled") as? Bool ?? true } + /// Field-test flag: "normally disconnected" model. When on, the auto-reconnect machinery is + /// suppressed (the pod is NOT held connected); PeripheralManager connects on demand for each + /// session and disconnects when idle, and we scan (advertisementMonitor) while disconnected. + /// This changes how Loop stays in touch with the pump — every command pays a connect first. + static var connectOnDemandEnabled: Bool { + UserDefaults.standard.object(forKey: "OmnipodKit.connectOnDemandEnabled") as? Bool ?? true + } + /// Connect-request timestamps (by peripheral UUID) for measuring connect latency in didConnect. private var connectRequestedAt: [String: Date] = [:] @@ -163,6 +171,18 @@ class BluetoothManager: NSObject { cm.connect(peripheral, options: nil) } + /// The keep-connected auto-reconnect. Suppressed in connect-on-demand mode, where the pod is + /// left disconnected between commands (and observable via advertisements) and connected on + /// demand by PeripheralManager. Explicit connects (pairing, retrieveAndConnectKnownPod, the + /// on-demand connect) do NOT route through here and are unaffected. + private func autoReconnect(_ peripheral: CBPeripheral) { + if BluetoothManager.connectOnDemandEnabled { + log.debug("[connectOnDemand] suppressing auto-reconnect to %{public}@", peripheral.identifier.uuidString) + return + } + timedConnect(peripheral) + } + init(podType: PodType) { self.podType = podType super.init() @@ -240,7 +260,7 @@ class BluetoothManager: NSObject { { self.log.default("connectToDevice: retrieved peripheral %{public}@ via retrievePeripherals", uuidString) self.addPeripheral(peripheral, podAdvertisement: nil) - self.timedConnect(peripheral) + self.autoReconnect(peripheral) } } } @@ -258,7 +278,7 @@ class BluetoothManager: NSObject { } let device = addPeripheral(peripheral, podAdvertisement: nil) autoConnectIDs.insert(uuidString) - timedConnect(peripheral) + autoReconnect(peripheral) log.default("retrieveAndConnectKnownPod: initiating connection to %{public}@", peripheral) result = device } @@ -282,7 +302,7 @@ class BluetoothManager: NSObject { if autoConnectIDs.contains(peripheral.identifier.uuidString) { if peripheral.state == .disconnected || peripheral.state == .disconnecting { log.info("updateConnections: Connecting to peripheral: %{public}@", peripheral) - timedConnect(peripheral) + autoReconnect(peripheral) } } else { if peripheral.state == .connected || peripheral.state == .connecting { @@ -309,7 +329,7 @@ class BluetoothManager: NSObject { let peripheral = device.manager.peripheral if peripheral.state == .disconnected || peripheral.state == .disconnecting { log.info("discoverPods: Connecting to peripheral: %{public}@", peripheral) - timedConnect(peripheral) + timedConnect(peripheral) // pairing/discovery — an explicit connect, not auto-reconnect } } startScanning() @@ -379,7 +399,7 @@ extension BluetoothManager: CBCentralManagerDelegate { if let newPeripheral = central.retrievePeripherals(withIdentifiers: [device.manager.peripheral.identifier]).first { log.debug("Re-connecting to known peripheral %{public}@", newPeripheral.identifier.uuidString) device.manager.peripheral = newPeripheral - timedConnect(newPeripheral) + autoReconnect(newPeripheral) } } @@ -392,7 +412,7 @@ extension BluetoothManager: CBCentralManagerDelegate { { log.default("[#%{public}@] Recovered peripheral from autoConnectIDs: %{public}@", instanceID, uuidString) addPeripheral(peripheral, podAdvertisement: nil) - timedConnect(peripheral) + autoReconnect(peripheral) } } @@ -464,10 +484,10 @@ extension BluetoothManager: CBCentralManagerDelegate { if discoveryModeEnabled && peripheral.state == .disconnected && podAdvertisement.pairable { // Connect to any pairable device, during discovery log.default("Connecting to pairable device %{public} in discovery mode", peripheral) - timedConnect(peripheral) + timedConnect(peripheral) // pairing — an explicit connect, not auto-reconnect } else if autoConnectIDs.contains(peripheral.identifier.uuidString) && peripheral.state == .disconnected { log.debug("Reonnecting to autoconnect device") - timedConnect(peripheral) + autoReconnect(peripheral) } else { log.info("Ignoring paired or unconnectable peripheral: %{public}@", peripheral) } @@ -519,7 +539,7 @@ extension BluetoothManager: CBCentralManagerDelegate { if autoConnectIDs.contains(peripheral.identifier.uuidString) { log.debug("Reconnecting disconnected autoconnect peripheral") - timedConnect(peripheral) + autoReconnect(peripheral) } } @@ -531,7 +551,7 @@ extension BluetoothManager: CBCentralManagerDelegate { connectionDelegate?.omnipodPeripheralDidFailToConnect(peripheral: peripheral, error: error) if autoConnectIDs.contains(peripheral.identifier.uuidString) { - timedConnect(peripheral) + autoReconnect(peripheral) } } } diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index eb4fd11..7e63a36 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -151,7 +151,18 @@ extension PeripheralManager { func configureAndRun(_ block: @escaping (_ manager: PeripheralManager) -> Void) -> (() -> Void) { return { - if self.needsReconnection { + if BluetoothManager.connectOnDemandEnabled { + // "Normally disconnected" model: the pod isn't held connected, so connect on demand + // for this session (no forceful reconnect — that heuristic is what caused the ~28s + // disconnect-then-wait stalls). If already connected (burst of sessions), no-op. + if self.peripheral.state != .connected { + do { + try self.connectOnDemand(timeout: 20) + } catch let error { + self.log.error("[connectOnDemand] on-demand connect failed: %{public}@", String(describing: error)) + } + } + } else if self.needsReconnection { self.log.default("Triggering forceful reconnect") do { try self.reconnect(timeout: 5) @@ -339,6 +350,20 @@ extension PeripheralManager { } } + /// Connect-on-demand: issue a real connect() and wait for didConnect. Unlike reconnect(), this + /// does NOT cancel first — it connects a peripheral we're deliberately keeping disconnected + /// between commands. Logs the measured connect latency (the number that decides viability). + func connectOnDemand(timeout: TimeInterval) throws { + guard peripheral.state != .connected else { return } + log.default("[connectOnDemand] connecting on demand (state=%{public}d, timeout=%{public}ds)", peripheral.state.rawValue, Int(timeout)) + let start = Date() + try runCommand(timeout: timeout) { + addCondition(.connect) + central?.connect(peripheral, options: nil) + } + log.default("[connectOnDemand] connected in %{public}@s", String(format: "%.3f", Date().timeIntervalSince(start))) + } + /// - Throws: PeripheralManagerError func setNotifyValue(_ enabled: Bool, for characteristic: CBCharacteristic, timeout: TimeInterval) throws { try runCommand(timeout: timeout) { @@ -648,7 +673,25 @@ extension PeripheralManager { manager.log.default("------------------------ %{public}@ ---------------------------", name) self?.idleStart = Date() self?.log.default("Start of idle at %{public}@", String(describing: self?.idleStart)) + self?.scheduleIdleDisconnectIfNeeded() } }) } + + /// Connect-on-demand: after a session goes idle, if no further session is queued, disconnect + /// the pod so it's left "normally disconnected" (and advertising/observable) between commands. + /// A short delay batches command bursts (status → bolus → status) into one connection. + private func scheduleIdleDisconnectIfNeeded() { + guard BluetoothManager.connectOnDemandEnabled else { return } + let idleDelay: TimeInterval = 4 + let idleAt = idleStart + queue.asyncAfter(deadline: .now() + idleDelay) { [weak self] in + guard let self = self, BluetoothManager.connectOnDemandEnabled else { return } + // Only disconnect if we're still idle (no newer session) and nothing is queued/running. + guard self.idleStart == idleAt, self.sessionQueue.operationCount == 0, + self.peripheral.state == .connected else { return } + self.log.default("[connectOnDemand] idle ~%{public}ds, no queued session -> disconnecting", Int(idleDelay)) + self.central?.cancelPeripheralConnection(self.peripheral) + } + } } From 13a96fab564c5b6c88d3948f8a37bc3017dbd2aa Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 2 Jul 2026 11:33:35 -0500 Subject: [PATCH 14/92] Connect-on-demand: don't bail early on disconnected pod in bleRunSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bleRunSession guarded 'manager.peripheral.state == .connected' and returned podNotConnected before ever calling manager.runSession — so in connect-on-demand mode (pod normally disconnected) every command failed instantly (podNotConnected in ~0.36s, no [connectOnDemand] line) and the configureAndRun connect-on-demand hook was never reached. Only require an existing connection in the classic held-connected mode. In connect-on-demand mode, proceed to manager.runSession -> configureAndRun, which connects on demand (and re-establishes the session) before the session block runs. --- OmnipodKit/Bluetooth/BlePodComms.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index 6ec6cf6..38e081e 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -690,7 +690,14 @@ class BlePodComms: PodComms { func bleRunSession(withName name: String, _ block: @escaping (_ result: SessionRunResult) -> Void) { - guard let manager = manager, manager.peripheral.state == .connected else { + guard let manager = manager else { + block(.failure(PodCommsError.podNotConnected)) + return + } + // In connect-on-demand mode the pod is normally disconnected; manager.runSession -> + // configureAndRun connects on demand before the session block runs. Only require an existing + // connection here in the classic "held connected" mode. + if !BluetoothManager.connectOnDemandEnabled, manager.peripheral.state != .connected { block(.failure(PodCommsError.podNotConnected)) return } From eb630f7427916fe74e880679c70608e28ce0a956 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 2 Jul 2026 11:43:37 -0500 Subject: [PATCH 15/92] =?UTF-8?q?Add=20=C2=A75=20beacon-capture=20mode:=20?= =?UTF-8?q?wildcard=20scan=20+=20[BEACON]=20raw-field=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the RE spec §5 (deterministic normal<->alarm diff). beaconCaptureEnabled flag (default ON, field-test only): scan withServices:nil (foreground, allowDuplicates) so we catch the pod's alarm/beacon advertisement even if it uses a service UUID we don't yet filter on (the CE1F923D-… 128-bit alarm beacon). didDiscover logs a full raw dump — every service UUID string (128-bit shows all 16 bytes), manufacturer data, localName, RSSI, state, connectable — tagged [BEACON] for any CE1F923D-prefixed frame, [ADV] for normal pod frames. Diff a triggered-alert capture vs normal to pin alarmCode/alertCode offsets + the stable background-filter UUID. Revert before merge. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 50 ++++++++++++++------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 61df5ef..4127ea8 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -159,6 +159,18 @@ class BluetoothManager: NSObject { UserDefaults.standard.object(forKey: "OmnipodKit.connectOnDemandEnabled") as? Bool ?? true } + /// §5 beacon-capture: scan withServices:nil (foreground, allowDuplicates) so we catch the pod's + /// alarm/beacon advertisement even if it advertises a service UUID we don't yet filter on (e.g. + /// the CE1F923D-… alarm beacon). Logs full raw fields for any pod-adjacent or CE1F923D frame so a + /// normal↔triggered-alert diff pins the alarm-code offsets + the stable background-filter UUID. + /// Heavy (wildcard foreground scan) — field-test only; revert before merge. + static var beaconCaptureEnabled: Bool { + UserDefaults.standard.object(forKey: "OmnipodKit.beaconCaptureEnabled") as? Bool ?? true + } + + /// Prefix of the DASH alarm/beacon 128-bit service UUID (per RE spec §3). + static let beaconUUIDPrefix = "CE1F923D-C539-48EA-7300-0A" + /// Connect-request timestamps (by peripheral UUID) for measuring connect latency in didConnect. private var connectRequestedAt: [String: Date] = [:] @@ -345,14 +357,19 @@ class BluetoothManager: NSObject { } else { serviceUUID = podType.blePodProfile.advertisementServiceUUID } - // Monitor mode: allowDuplicates so we see the advertisement cadence (foreground only — + // Monitor/beacon mode: allowDuplicates so we see the advertisement cadence (foreground only — // iOS coalesces duplicates in the background). - let options: [String: Any] = BluetoothManager.advertisementMonitorEnabled + let options: [String: Any] = (BluetoothManager.advertisementMonitorEnabled || BluetoothManager.beaconCaptureEnabled) ? [CBCentralManagerScanOptionAllowDuplicatesKey: true] : [:] - log.default("Start scanning for %{public}@ (advertisementMonitor=%{public}@, allowDuplicates=%{public}@)", - serviceUUID.uuidString, String(describing: BluetoothManager.advertisementMonitorEnabled), + // §5: scan withServices:nil so we catch a beacon advertising a UUID we don't yet filter on. + // Wildcard is foreground-only; the whole point of §5 is to discover the stable filter UUID. + let services: [CBUUID]? = BluetoothManager.beaconCaptureEnabled ? nil : [serviceUUID] + log.default("Start scanning (filter=%{public}@, advertisementMonitor=%{public}@, beaconCapture=%{public}@, allowDuplicates=%{public}@)", + services == nil ? "nil (wildcard)" : serviceUUID.uuidString, + String(describing: BluetoothManager.advertisementMonitorEnabled), + String(describing: BluetoothManager.beaconCaptureEnabled), String(describing: options[CBCentralManagerScanOptionAllowDuplicatesKey] != nil)) - manager.scanForPeripherals(withServices: [serviceUUID], options: options) + manager.scanForPeripherals(withServices: services, options: options) } private func stopScanning() { @@ -460,21 +477,24 @@ extension BluetoothManager: CBCentralManagerDelegate { log.debug("%{public}@: %{public}@, %{public}@", #function, peripheral, advertisementData) - // Full advertisement dump for pod-identified peripherals — to test the "faults via - // advertisement" model: capture every field (esp. the service-UUID list, which changed - // between scans early on) + RSSI + connectable + peripheral state, every time. - if BluetoothManager.advertisementMonitorEnabled, - autoConnectIDs.contains(peripheral.identifier.uuidString) || PodAdvertisement(advertisementData, podType: podType) != nil { - let svcUUIDs = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])?.map { $0.uuidString }.joined(separator: ",") ?? "-" + // Full advertisement dump for pod-adjacent frames — the raw material for §5 (normal↔alarm + // diff) and the "faults via advertisement" model. Captures every field, every time. + let advSvcUUIDs = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]) ?? [] + let isBeaconFrame = advSvcUUIDs.contains { $0.uuidString.uppercased().hasPrefix(BluetoothManager.beaconUUIDPrefix) } + let isPodFrame = autoConnectIDs.contains(peripheral.identifier.uuidString) || PodAdvertisement(advertisementData, podType: podType) != nil + if (BluetoothManager.advertisementMonitorEnabled || BluetoothManager.beaconCaptureEnabled), isPodFrame || isBeaconFrame { + let svcUUIDs = advSvcUUIDs.map { $0.uuidString }.joined(separator: ",") let mfg = (advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data)?.hexadecimalString ?? "-" let svcData = (advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data])? .map { "\($0.key.uuidString):\($0.value.hexadecimalString)" }.joined(separator: ",") ?? "-" let connectable = advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "-" - log.default("[ADV] %{public}@ rssi=%{public}@ state=%{public}@ connectable=%{public}@ name=%{public}@ svcUUIDs=[%{public}@] mfg=%{public}@ svcData=%{public}@", - peripheral.identifier.uuidString, RSSI, String(describing: peripheral.state.rawValue), - String(describing: connectable), name, svcUUIDs, mfg, svcData) - } else if let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data { + // Tag beacon frames distinctly so the §5 diff is trivial to grep. + let tag = isBeaconFrame ? "[BEACON]" : "[ADV]" + log.default("%{public}@ %{public}@ rssi=%{public}@ state=%{public}@ connectable=%{public}@ name=%{public}@ svcUUIDs=[%{public}@] mfg=%{public}@ svcData=%{public}@", + tag, peripheral.identifier.uuidString, RSSI, String(describing: peripheral.state.rawValue), + String(describing: connectable), name.isEmpty ? "-" : name, svcUUIDs.isEmpty ? "-" : svcUUIDs, mfg, svcData) + } else if let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data, BluetoothManager.advertisementMonitorEnabled { log.default("[SCAN] ManufacturerData: %{public}@ (%{public}d bytes)", mfgData.hexadecimalString, mfgData.count) } From 5e38237ed98c3ffcfb21822996856ac6c690cce9 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 2 Jul 2026 12:00:19 -0500 Subject: [PATCH 16/92] Connect-on-demand: adopt PeripheralManager while disconnected (fix podNotConnected for all commands) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chicken-and-egg: BlePodComms.manager is set only in omnipodPeripheralDidConnect (on connect) or restore. In connect-on-demand the pod is normally disconnected and auto-reconnect is suppressed, so on a fresh launch nothing connects, manager stays nil, and bleRunSession's 'guard let manager' bails with podNotConnected — for every command — with no way to bootstrap the first connect. Fix: BluetoothManager.peripheralManager(forIdentifier:) returns a known device's PeripheralManager connected or not; bleRunSession adopts it from the device list when self.manager is nil in connect-on-demand mode, so manager.runSession -> configureAndRun can start the first on-demand connect. Also suppress the [SCAN] mfg-data log in beacon-capture (wildcard) mode to cut noise. --- OmnipodKit/Bluetooth/BlePodComms.swift | 13 +++++++++++++ OmnipodKit/Bluetooth/BluetoothManager.swift | 15 ++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index 38e081e..dc2e5b5 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -690,7 +690,20 @@ class BlePodComms: PodComms { func bleRunSession(withName name: String, _ block: @escaping (_ result: SessionRunResult) -> Void) { + // In connect-on-demand mode the pod is normally disconnected, and self.manager (set only in + // omnipodPeripheralDidConnect / on restore) is nil on a fresh launch — nothing has connected + // yet. Adopt the pod's PeripheralManager from the device list (it exists while disconnected) + // so configureAndRun can bootstrap the first on-demand connect. Without this, every command + // failed with podNotConnected and the connect could never start. + if manager == nil, BluetoothManager.connectOnDemandEnabled, let bleId = podState?.bleIdentifier { + self.manager = bluetoothManager.peripheralManager(forIdentifier: bleId) + if self.manager != nil { + log.default("[connectOnDemand] adopted PeripheralManager for %{public}@ while disconnected", bleId) + } + } + guard let manager = manager else { + log.default("[connectOnDemand] no PeripheralManager for pod yet (not discovered) — podNotConnected") block(.failure(PodCommsError.podNotConnected)) return } diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 4127ea8..af46c05 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -387,6 +387,17 @@ class BluetoothManager: NSObject { return connected } + /// The PeripheralManager for a known device by peripheral UUID — connected OR NOT. Connect-on-demand + /// uses this to obtain the pod's manager while disconnected (BlePodComms.manager is otherwise only + /// set in omnipodPeripheralDidConnect, so it's nil on a fresh launch when auto-reconnect is off). + func peripheralManager(forIdentifier uuidString: String) -> PeripheralManager? { + var result: PeripheralManager? + managerQueue.sync { + result = self.devices.first(where: { $0.manager.peripheral.identifier.uuidString == uuidString })?.manager + } + return result + } + override var debugDescription: String { var report = [ @@ -494,7 +505,9 @@ extension BluetoothManager: CBCentralManagerDelegate { log.default("%{public}@ %{public}@ rssi=%{public}@ state=%{public}@ connectable=%{public}@ name=%{public}@ svcUUIDs=[%{public}@] mfg=%{public}@ svcData=%{public}@", tag, peripheral.identifier.uuidString, RSSI, String(describing: peripheral.state.rawValue), String(describing: connectable), name.isEmpty ? "-" : name, svcUUIDs.isEmpty ? "-" : svcUUIDs, mfg, svcData) - } else if let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data, BluetoothManager.advertisementMonitorEnabled { + } else if let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data, + BluetoothManager.advertisementMonitorEnabled, !BluetoothManager.beaconCaptureEnabled { + // Suppressed in beacon-capture (wildcard) mode — this fired for every nearby BLE device. log.default("[SCAN] ManufacturerData: %{public}@ (%{public}d bytes)", mfgData.hexadecimalString, mfgData.count) } From ad2e3753d4a2b98f3016e8613af61ad3984fb862 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 2 Jul 2026 12:06:44 -0500 Subject: [PATCH 17/92] Connect-on-demand: runCommand allowDisconnected for the connect itself connectOnDemand issues the connect via runCommand + addCondition(.connect), but runCommand's prelude guarded 'peripheral.state == .connected' and threw notReady immediately (the connect starts from disconnected). reconnect() only worked because it ran on an already-connected pod. Add allowDisconnected (default false) to runCommand; connectOnDemand passes true so the connect command is allowed from the disconnected state and completes via the .connect condition/didConnect. --- OmnipodKit/Bluetooth/PeripheralManager.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index 7e63a36..b139aa8 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -248,10 +248,12 @@ extension PeripheralManager { // MARK: - Synchronous Commands extension PeripheralManager { /// - Throws: PeripheralManagerError - func runCommand(timeout: TimeInterval, command: () -> Void) throws { + func runCommand(timeout: TimeInterval, allowDisconnected: Bool = false, command: () -> Void) throws { // Prelude dispatchPrecondition(condition: .onQueue(queue)) - guard central?.state == .poweredOn && peripheral.state == .connected else { + // allowDisconnected: for connect-on-demand, the command IS the connect — the peripheral is + // legitimately disconnected here and becomes connected via the .connect condition. + guard central?.state == .poweredOn && (allowDisconnected || peripheral.state == .connected) else { self.log.info("runCommand guard failed - bluetooth not running or peripheral not connected: peripheral %@", peripheral) self.log.info("runCommand guard failed - not ready: peripheral=%{public}@ centralState=%{public}@ peripheralState=%{public}@ queueDepth=%{public}d commandConditions=%{public}@", peripheral, @@ -357,7 +359,7 @@ extension PeripheralManager { guard peripheral.state != .connected else { return } log.default("[connectOnDemand] connecting on demand (state=%{public}d, timeout=%{public}ds)", peripheral.state.rawValue, Int(timeout)) let start = Date() - try runCommand(timeout: timeout) { + try runCommand(timeout: timeout, allowDisconnected: true) { addCondition(.connect) central?.connect(peripheral, options: nil) } From 815f65e9dfd73f0c03dace392df3ea1a144a082c Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 2 Jul 2026 12:13:23 -0500 Subject: [PATCH 18/92] Connect-on-demand: stop scanning during the connect (allowDuplicates scan starves connect) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on-demand connect went to .connecting and never completed (didConnect never fired, 20s timeout) because a continuous allowDuplicates/wildcard scan was running the whole time — on iOS an active allowDuplicates scan starves connection completion (the radio never gets a connect window; the scan even kept re-discovering the pod mid-connect). - connectOnDemand: central.stopScan() before the connect; on failure, cancelPeripheralConnection to unstick the wedged .connecting state (so a disconnect/fail event fires). - BluetoothManager.resumeScanIfNeeded(): restart the monitor/beacon scan on didDisconnect / didFailToConnect, only when nothing is connected/connecting — so we scan while disconnected but never during a command. Net: disconnected -> scan; command needed -> stop scan, connect, run, idle-disconnect -> resume scan. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 13 +++++++++++++ OmnipodKit/Bluetooth/PeripheralManager.swift | 17 ++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index af46c05..d59985e 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -377,6 +377,17 @@ class BluetoothManager: NSObject { manager.stopScan() } + /// Resume the monitor/beacon scan after a connect attempt ends (connect-on-demand stops the scan + /// during the connect because an active allowDuplicates scan starves connection completion). + /// Only when nothing is connected, so we never scan while a command is using the link. + private func resumeScanIfNeeded() { + guard BluetoothManager.advertisementMonitorEnabled || BluetoothManager.beaconCaptureEnabled else { return } + guard manager?.state == .poweredOn, !manager.isScanning else { return } + guard !devices.contains(where: { $0.manager.peripheral.state == .connected || $0.manager.peripheral.state == .connecting }) else { return } + log.default("[connectOnDemand] resuming scan after connect attempt") + startScanning() + } + // MARK: - Accessors func getConnectedDevices() -> [Omni] { @@ -574,6 +585,7 @@ extension BluetoothManager: CBCentralManagerDelegate { log.debug("Reconnecting disconnected autoconnect peripheral") autoReconnect(peripheral) } + resumeScanIfNeeded() } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { @@ -586,5 +598,6 @@ extension BluetoothManager: CBCentralManagerDelegate { if autoConnectIDs.contains(peripheral.identifier.uuidString) { autoReconnect(peripheral) } + resumeScanIfNeeded() } } diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index b139aa8..7fb1814 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -358,10 +358,21 @@ extension PeripheralManager { func connectOnDemand(timeout: TimeInterval) throws { guard peripheral.state != .connected else { return } log.default("[connectOnDemand] connecting on demand (state=%{public}d, timeout=%{public}ds)", peripheral.state.rawValue, Int(timeout)) + // An active scan — especially allowDuplicates / wildcard — starves connection completion on + // iOS (didConnect never fires). Stop scanning for the duration of the connect; BluetoothManager + // resumes it on didConnect-then-disconnect / didFailToConnect. + central?.stopScan() let start = Date() - try runCommand(timeout: timeout, allowDisconnected: true) { - addCondition(.connect) - central?.connect(peripheral, options: nil) + do { + try runCommand(timeout: timeout, allowDisconnected: true) { + addCondition(.connect) + central?.connect(peripheral, options: nil) + } + } catch { + // Unstick a connect that never completed, so didDisconnect/didFailToConnect fires + // (which resumes the scan) instead of leaving it wedged in .connecting. + central?.cancelPeripheralConnection(peripheral) + throw error } log.default("[connectOnDemand] connected in %{public}@s", String(format: "%.3f", Date().timeIntervalSince(start))) } From 4694f59635bbd53869fb72da18dc91cf670e74ca Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 2 Jul 2026 12:27:36 -0500 Subject: [PATCH 19/92] =?UTF-8?q?Add=20debug=20Trigger-Test-Alert=20to=20f?= =?UTF-8?q?ire=20a=20non-fault=20pod=20alert=20for=20=C2=A75=20beacon=20ca?= =?UTF-8?q?pture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suspend didn't change the advertisement (it's a state, not an alarm) and low-reservoir can't be triggered above ~50U. Add a debug button (Pod Diagnostics -> Trigger Test Alert) that programs a real expiration-reminder alert ~60s out via configureAlerts — mirrors updateExpirationReminder. It's an alert not a fault (acknowledge to clear; pod stays usable), reservoir-independent, and repeatable. When it fires the pod beeps and — per the RE spec — should flip its advertisement to the AS/CE1F923D beacon, which the §5 [BEACON] logging then captures. Note: reuses the expiration-reminder slot, so it overwrites the pod's expiration reminder — reset it in settings after testing. Field-test only; remove before merge. --- OmnipodKit/PumpManager/OmniPumpManager.swift | 33 +++++++++++++++++++ .../Views/PodDiagnosticsView.swift | 10 ++++++ 2 files changed, 43 insertions(+) diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index 031512d..270a2fb 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -1814,6 +1814,39 @@ extension OmniPumpManager { #endif } + /// DEBUG (field test): program a real, non-fault pod alert to fire ~60s from now, so we can + /// capture the pod's alarm/beacon advertisement (§5) non-destructively. Uses the expiration- + /// reminder slot with a near-future absAlertTime — the pod beeps and (per the RE spec) should + /// flip its advertisement to the AS/beacon state. It's an alert, not a fault: acknowledge it + /// away and the pod is fine. NOTE: this overwrites the pod's expiration reminder — reset that in + /// settings after testing. + func triggerTestAlert() async throws { + guard self.hasActivePod else { + throw OmniPumpManagerError.noPodPaired + } + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.runSession(withName: "Trigger Test Alert") { (result) in + switch result { + case .success(let session): + self.handleSilencePodEnd(session: session) + let podTime = self.podTime + let alertPodTime = podTime + TimeInterval(seconds: 60) // fire ~60s from now (mirrors updateExpirationReminder math) + let testAlert = PodAlert.expirationReminder(offset: podTime, absAlertTime: alertPodTime, silent: false) + do { + let beepBlock = self.beepMessageBlock(beepType: .beep) + let _ = try session.configureAlerts([testAlert], beepBlock: beepBlock) + self.log.default("[testAlert] scheduled expirationReminder to fire in ~60s (podTime=%{public}@, alertPodTime=%{public}@)", podTime.timeIntervalStr, alertPodTime.timeIntervalStr) + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + func playTestBeeps() async throws { guard self.hasActivePod else { throw OmniPumpManagerError.noPodPaired diff --git a/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift b/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift index 69b5089..8cabb91 100644 --- a/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift @@ -14,6 +14,7 @@ import HealthKit protocol DiagnosticCommands { + func triggerTestAlert() async throws func playTestBeeps() async throws func readPulseLog() async throws -> String func readPulseLogPlus() async throws -> String @@ -47,6 +48,15 @@ struct PodDiagnosticsView: View { } .disabled(!podOk) + // DEBUG (field test): fire a real non-fault alert ~60s out to capture the beacon (§5). + Button(action: { + Task { try? await diagnosticCommands.triggerTestAlert() } + }) { + Text("Trigger Test Alert (debug, fires in ~60s)") + .foregroundColor(Color.primary) + } + .disabled(!podOk) + NavigationLink(destination: ReadPodInfoView( title: LocalizedString("Read Pulse Log", comment: "Text for read pulse log title"), actionString: LocalizedString("Reading Pulse Log...", comment: "Text for read pulse log action"), From 6c72acfabce31f190441b7f8d4c7237aee85f360 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 2 Jul 2026 14:06:23 -0500 Subject: [PATCH 20/92] DASH alert findings writeup + prototype connectionless alert detector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (a) DASH_BEACON_FINDINGS.md: handoff correction to the RE beacon spec. Alerts do NOT use the CE1F923D non-connectable beacon — they ride the normal 16-bit advertisement, encoded in the 2nd service UUID (C001 clear <-> C005 alert) and a 4-byte mfg status word (00020000 clear -> 000a0008 alert). Reversible on acknowledge. CE1F923D is presumed the fault-only path (untested). (b) BluetoothManager.detectPodAlertStatus: prototype connectionless detection. Extracts the mfg status word (end-anchored, before the address tail) on every pod advertisement, logs [POD-STATUS] on change and [POD-ALERT] on clear<->alert transitions — no connection needed. Stage-2 TODO: route a confirmed transition to the pump manager once the per-alert bit mapping is nailed down. --- DASH_BEACON_FINDINGS.md | 70 +++++++++++++++++++++ OmnipodKit/Bluetooth/BluetoothManager.swift | 40 +++++++++++- 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 DASH_BEACON_FINDINGS.md diff --git a/DASH_BEACON_FINDINGS.md b/DASH_BEACON_FINDINGS.md new file mode 100644 index 0000000..75b81d4 --- /dev/null +++ b/DASH_BEACON_FINDINGS.md @@ -0,0 +1,70 @@ +# DASH Advertisement / Alert Signalling — Field Findings + +Correction + data addendum to the RE "Omnipod DASH Advertisement (Beacon) Parsing" spec, +from live capture on a real DASH pod (`DD5D6B83…`, address `0x179F0CF1`). + +## Test setup +- OmnipodKit driven in a **connect-on-demand / "normally disconnected"** mode: the pod is left + disconnected and advertising; the phone scans continuously (`scanForPeripherals(withServices: + nil, options:[allowDuplicates:true])`, foreground) and connects only to run a command. +- Every pod-adjacent advertisement is logged with all raw fields (`[ADV]` / `[BEACON]`). +- A **non-destructive alert** (expiration reminder, ~60 s out via `configureAlerts`) was triggered + to move the pod into an alerting state, then acknowledged to clear it — no fault, pod reusable. + +## Headline correction to the spec +For an **alert**, the pod does **NOT** switch to the non-connectable `CE1F923D-…` 128-bit beacon +described in the spec. It stays **`connectable = 1`** and keeps advertising its **normal 16-bit +service-UUID list**, encoding the alert state in two changing fields: + +- the **2nd 16-bit service UUID**, and +- a **4-byte status word inside the manufacturer data**. + +The `CE1F923D` beacon is therefore most likely a **fault-only** path (a terminal fault where the +pod goes non-connectable). That was **not** reproduced here (would require sacrificing the pod). + +## The normal ↔ alert ↔ cleared diff (measured) + +| state | 2nd service UUID | mfg status word | +|---|---|---| +| normal (idle) | `C001` | `00020000` | +| **alert active** (beeping) | `C005` | `000a0008` | +| acknowledged (cleared) | `C001` | `00020000` (reverts) | + +Reversible and repeatable. The 2nd UUID and the mfg status word move together. + +## Advertisement field layout (observed, DASH) + +Service UUIDs (16-bit), example alerting frame: +``` +[4024, C005, 000A, 179F, 0CF1, 0859, 1693, 0015, 5E7B] + │ │ │ └──┬──┘ ...other constant fields... + │ │ │ └ 179F,0CF1 = pod address 0x179F0CF1 + │ │ └ 000A = constant ("third service") + │ └ 2nd UUID = STATUS/ALERT carrier: C001 (clear) ↔ C005 (alert) + └ 4024 = DASH main/advertisement service (scan-filter target today) +``` + +Manufacturer data (company ID `0x0360`), normal vs alert: +``` +normal: 6003 021595a189ee252a4c7886a37c2a 000a 00020000 f10c bc +alert: 6003 021595a189ee252a4c7886a37c2a 000a 000a0008 f10c bc + └CID └── pod id / serial (const) ──┘ └cst └STATUS┘ └addr└? +``` +- `STATUS` = 4-byte word between the constant `000a` and the address tail `f10c`. +- clear `0x00020000` (bit 17) → alert `0x000a0008` (adds bits 3 and 19 on top). + Consistent with the spec's "alarmCode/alertCode as a bit-field." + +## Open items (need more pods / a real fault; not tested here) +1. **Per-alert bit mapping** — only one alert type (expiration reminder) was fired. Which + bit/UUID-value corresponds to which specific alert/alarm needs several alert types diffed. +2. **Fault / `CE1F923D` path** — untested; the spec's 128-bit non-connectable beacon is presumed + the fault path. Needs a deliberately faulted (sacrificed) pod. +3. **Background-scan filter UUID** — the alert rides the `4024` advertisement, so today's + `withServices:[4024]` filter would catch alert changes in the background. The `CE1F923D` fault + beacon may need a different/additional filter — confirm when the fault path is captured. + +## Practical takeaway for OmnipodKit +Connectionless **alert detection is achievable now**: in the scan callback, read the 2nd 16-bit +service UUID and/or the mfg `STATUS` word and raise an alert to the pump manager when it deviates +from the clear baseline (`C001` / `00020000`) — no connection required. Detail/acknowledge still +needs a (slow, ~11–14 s) on-demand connect, but detection itself is instant. diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index d59985e..11a844f 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -103,7 +103,10 @@ class BluetoothManager: NSObject { /// Isolated to `managerQueue` private var devices: [Omni] = [] - + + /// Last-seen DASH advertisement status word per peripheral, for connectionless alert detection. + private var lastPodStatusWord: [String: Data] = [:] + /// Isolated to `managerQueue` private var discoveryModeEnabled: Bool = false @@ -494,6 +497,37 @@ extension BluetoothManager: CBCentralManagerDelegate { } } + /// The DASH "clear / no alert" status word (see DASH_BEACON_FINDINGS.md). Any other value while + /// the pod is otherwise healthy indicates an active alert/alarm. + private static let podStatusClear = Data([0x00, 0x02, 0x00, 0x00]) + + /// Extract the 4-byte DASH status word from the manufacturer data: it sits immediately before the + /// 3-byte address+trailer tail (…000a‹STATUS›f10cbc). End-anchored so it's robust to the fixed + /// pod-id prefix. Returns nil if the mfg data isn't the expected DASH shape. + private func podStatusWord(from advertisementData: [String: Any]) -> Data? { + guard let mfg = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data, mfg.count >= 8 else { return nil } + return mfg.subdata(in: (mfg.count - 7)..<(mfg.count - 3)) + } + + /// PROTOTYPE connectionless alert detection (§5 finding): read the pod's alert state straight from + /// its advertisement — no connection needed. Logs the status word and flags clear↔alert transitions. + /// TODO(stage 2): route a confirmed alert transition to the pump manager (raise/clear a pod alert) + /// once the per-alert bit mapping is confirmed across more alert types, to avoid false positives. + private func detectPodAlertStatus(peripheral: CBPeripheral, advertisementData: [String: Any]) { + guard let status = podStatusWord(from: advertisementData) else { return } + let id = peripheral.identifier.uuidString + guard lastPodStatusWord[id] != status else { return } // only on change + let wasAlert = lastPodStatusWord[id].map { $0 != BluetoothManager.podStatusClear } + let isAlert = status != BluetoothManager.podStatusClear + lastPodStatusWord[id] = status + log.default("[POD-STATUS] %{public}@ status=%{public}@ (%{public}@) — connectionless detect", + id, status.hexadecimalString, isAlert ? "non-clear/ALERT" : "clear") + if wasAlert != isAlert { + log.default("[POD-ALERT] %{public}@ → %{public}@ (from advertisement, no connect)", + id, isAlert ? "ALERT ACTIVE" : "CLEARED") + } + } + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { dispatchPrecondition(condition: .onQueue(managerQueue)) @@ -522,6 +556,10 @@ extension BluetoothManager: CBCentralManagerDelegate { log.default("[SCAN] ManufacturerData: %{public}@ (%{public}d bytes)", mfgData.hexadecimalString, mfgData.count) } + if isPodFrame { + detectPodAlertStatus(peripheral: peripheral, advertisementData: advertisementData) + } + if let podAdvertisement = PodAdvertisement(advertisementData, podType: podType) { addPeripheral(peripheral, podAdvertisement: podAdvertisement) From 5d5140e40bf63c7fe5940b84dc36b8019bcdcf5f Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 2 Jul 2026 14:25:09 -0500 Subject: [PATCH 21/92] Connect-on-demand (c): light scan during connect to cut the ~11-14s latency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Going fully dark for the connect made iOS fall back to its sparse connection duty cycle (~11-14s, sometimes >20s timeout). It's the allowDuplicates flood that starved the connect earlier, not scanning per se — so connectOnDemand now drops the heavy monitor scan and runs a LIGHT non-allowDuplicates scan during the connect, so iOS actively hears the pod (~1Hz) and completes the connect fast. BluetoothManager stops this helper scan on didConnect (no scanning while connected); the monitor scan is restored on the next disconnect via resumeScanIfNeeded. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 6 ++++++ OmnipodKit/Bluetooth/PeripheralManager.swift | 9 ++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 11a844f..892944e 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -586,6 +586,12 @@ extension BluetoothManager: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { dispatchPrecondition(condition: .onQueue(managerQueue)) + // Connected — stop the connect-helper scan (connectOnDemand started a light scan to speed the + // connect). We don't scan while connected; the monitor scan is restored on the next disconnect. + if manager.isScanning { + manager.stopScan() + } + if let requestedAt = connectRequestedAt.removeValue(forKey: peripheral.identifier.uuidString) { let latency = String(format: "%.3f", Date().timeIntervalSince(requestedAt)) log.default("[#%{public}@] CONNECTED: %{public}@ — connect latency %{public}@s (known device: %{public}@)", diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index 7fb1814..12191ab 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -358,10 +358,13 @@ extension PeripheralManager { func connectOnDemand(timeout: TimeInterval) throws { guard peripheral.state != .connected else { return } log.default("[connectOnDemand] connecting on demand (state=%{public}d, timeout=%{public}ds)", peripheral.state.rawValue, Int(timeout)) - // An active scan — especially allowDuplicates / wildcard — starves connection completion on - // iOS (didConnect never fires). Stop scanning for the duration of the connect; BluetoothManager - // resumes it on didConnect-then-disconnect / didFailToConnect. + // Going fully dark makes iOS fall back to its sparse connection duty cycle (~11-14s to + // connect). Instead: drop the heavy allowDuplicates monitor scan, then run a LIGHT scan + // (non-allowDuplicates) so iOS actively hears the pod (~1Hz) and completes the connect fast. + // It's the allowDuplicates flood — not scanning per se — that starved the connect earlier. + // BluetoothManager stops this helper scan on didConnect and restores the monitor on disconnect. central?.stopScan() + central?.scanForPeripherals(withServices: nil, options: nil) let start = Date() do { try runCommand(timeout: timeout, allowDisconnected: true) { From 91a51580731b29bcc23931891d8334b57af5c86d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 2 Jul 2026 14:36:26 -0500 Subject: [PATCH 22/92] Low-power fault-watch (option 3): scan filtered on the alarm service UUID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lowPowerMonitorEnabled (default ON): scan withServices:[alarm UUIDs] (currently [C005], the confirmed alert-state 2nd service UUID) with allowDuplicates OFF, taking precedence over the monitor/beacon scans. iOS then only delivers didDiscover — only wakes the app — when the pod enters an alarm state; zero wakes during normal operation, and it carries into the background via the existing State Preservation/Restoration (restore now saves this filter as servicesToScan). Trade-off (see DASH_BEACON_FINDINGS.md): only catches enumerated alarm UUIDs (extend alarmServiceUUIDs as more alert/fault types are captured); the clear transition isn't seen by this filter — confirm on the next connect. resumeScanIfNeeded restarts it after a disconnect. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 47 ++++++++++++++++----- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 892944e..eff8cb2 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -174,6 +174,21 @@ class BluetoothManager: NSObject { /// Prefix of the DASH alarm/beacon 128-bit service UUID (per RE spec §3). static let beaconUUIDPrefix = "CE1F923D-C539-48EA-7300-0A" + /// Low-power fault-watch (option 3): scan filtered on the DASH ALARM service UUID(s) with + /// allowDuplicates OFF, so iOS only wakes us when the pod enters an alarm state (2nd service + /// UUID flips to an alarm value) — zero wakes during normal operation, and it survives into the + /// background via State Preservation/Restoration. Takes precedence over the monitor/beacon scans. + /// Trade-off: only catches the enumerated alarm UUIDs below (currently just the one confirmed + /// alert value); the clear transition isn't caught here (confirm on the next connect). See + /// DASH_BEACON_FINDINGS.md. Add more alarm UUID values as they're discovered. + static var lowPowerMonitorEnabled: Bool { + UserDefaults.standard.object(forKey: "OmnipodKit.lowPowerMonitorEnabled") as? Bool ?? true + } + + /// Known DASH alarm-state 16-bit service UUIDs (the 2nd UUID while alerting). `C005` = confirmed + /// (expiration reminder); extend as more alert/alarm types are captured. + static let alarmServiceUUIDs = [CBUUID(string: "C005")] + /// Connect-request timestamps (by peripheral UUID) for measuring connect latency in didConnect. private var connectRequestedAt: [String: Date] = [:] @@ -360,16 +375,26 @@ class BluetoothManager: NSObject { } else { serviceUUID = podType.blePodProfile.advertisementServiceUUID } - // Monitor/beacon mode: allowDuplicates so we see the advertisement cadence (foreground only — - // iOS coalesces duplicates in the background). - let options: [String: Any] = (BluetoothManager.advertisementMonitorEnabled || BluetoothManager.beaconCaptureEnabled) - ? [CBCentralManagerScanOptionAllowDuplicatesKey: true] : [:] - // §5: scan withServices:nil so we catch a beacon advertising a UUID we don't yet filter on. - // Wildcard is foreground-only; the whole point of §5 is to discover the stable filter UUID. - let services: [CBUUID]? = BluetoothManager.beaconCaptureEnabled ? nil : [serviceUUID] - log.default("Start scanning (filter=%{public}@, advertisementMonitor=%{public}@, beaconCapture=%{public}@, allowDuplicates=%{public}@)", - services == nil ? "nil (wildcard)" : serviceUUID.uuidString, - String(describing: BluetoothManager.advertisementMonitorEnabled), + let services: [CBUUID]? + let options: [String: Any] + if BluetoothManager.lowPowerMonitorEnabled { + // Option 3: wake only on an alarm-state advertisement. Filter on the alarm UUID(s), no + // allowDuplicates. Takes precedence over the monitor/beacon scans. + services = BluetoothManager.alarmServiceUUIDs + options = [:] + } else if BluetoothManager.beaconCaptureEnabled { + // §5: scan withServices:nil (wildcard) + allowDuplicates so we catch a beacon advertising + // a UUID we don't yet filter on. Foreground-only (the point of §5 is to find the filter UUID). + services = nil + options = [CBCentralManagerScanOptionAllowDuplicatesKey: true] + } else { + // Monitor mode: filter on the pod's main service; allowDuplicates to see the advert cadence. + services = [serviceUUID] + options = BluetoothManager.advertisementMonitorEnabled ? [CBCentralManagerScanOptionAllowDuplicatesKey: true] : [:] + } + log.default("Start scanning (filter=%{public}@, lowPowerMonitor=%{public}@, beaconCapture=%{public}@, allowDuplicates=%{public}@)", + services == nil ? "nil (wildcard)" : services!.map { $0.uuidString }.joined(separator: ","), + String(describing: BluetoothManager.lowPowerMonitorEnabled), String(describing: BluetoothManager.beaconCaptureEnabled), String(describing: options[CBCentralManagerScanOptionAllowDuplicatesKey] != nil)) manager.scanForPeripherals(withServices: services, options: options) @@ -384,7 +409,7 @@ class BluetoothManager: NSObject { /// during the connect because an active allowDuplicates scan starves connection completion). /// Only when nothing is connected, so we never scan while a command is using the link. private func resumeScanIfNeeded() { - guard BluetoothManager.advertisementMonitorEnabled || BluetoothManager.beaconCaptureEnabled else { return } + guard BluetoothManager.advertisementMonitorEnabled || BluetoothManager.beaconCaptureEnabled || BluetoothManager.lowPowerMonitorEnabled else { return } guard manager?.state == .poweredOn, !manager.isScanning else { return } guard !devices.contains(where: { $0.manager.peripheral.state == .connected || $0.manager.peripheral.state == .connecting }) else { return } log.default("[connectOnDemand] resuming scan after connect attempt") From feeb7a74bf2c4a8ee69e2ba878c02389a2ee9d43 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 2 Jul 2026 14:47:42 -0500 Subject: [PATCH 23/92] Reconciliation capture: wildcard idle scan + advert cadence + candidate 128-bit alarm UUIDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RE binary model says alarms are 128-bit CE1F923D-…-0A beacons (TT 02/03), but our field captures show only 16-bit UUIDs (C001<->C005) and never a CE1F923D frame. To settle it: - lowPowerMonitorEnabled default -> false, so beacon-capture wildcard runs and would catch a CE1F923D frame (our [BEACON] prefix match) if this pod ever emits one. - [ADV]/[BEACON] now logs dt= (inter-frame delta) so the disconnected-advert cadence is directly measurable — the RE's DS-beacon-rate / periodic-wake question. - alarmServiceUUIDs adds the candidate 128-bit AS/AST built from the RE template with deviceId guessed = pod address 179F0CF1 (UNCONFIRMED; harmless if wrong). Fix from a real [BEACON] capture before relying on them. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 36 ++++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index eff8cb2..5a1eb84 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -107,6 +107,10 @@ class BluetoothManager: NSObject { /// Last-seen DASH advertisement status word per peripheral, for connectionless alert detection. private var lastPodStatusWord: [String: Data] = [:] + /// Last advertisement timestamp per peripheral, to log inter-frame cadence (the DS-beacon-rate + /// measurement the RE asked for — is there a usable periodic wake?). + private var lastAdvSeen: [String: Date] = [:] + /// Isolated to `managerQueue` private var discoveryModeEnabled: Bool = false @@ -181,13 +185,25 @@ class BluetoothManager: NSObject { /// Trade-off: only catches the enumerated alarm UUIDs below (currently just the one confirmed /// alert value); the clear transition isn't caught here (confirm on the next connect). See /// DASH_BEACON_FINDINGS.md. Add more alarm UUID values as they're discovered. + /// NOTE: default flipped to FALSE to run the reconciliation experiment — a clean wildcard idle + /// capture to confirm whether this pod EVER emits a 128-bit CE1F923D beacon (RE binary model) or + /// only the 16-bit C005 alarm signal we've observed. Re-enable once the true alarm UUID is confirmed. static var lowPowerMonitorEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.lowPowerMonitorEnabled") as? Bool ?? true - } - - /// Known DASH alarm-state 16-bit service UUIDs (the 2nd UUID while alerting). `C005` = confirmed - /// (expiration reminder); extend as more alert/alarm types are captured. - static let alarmServiceUUIDs = [CBUUID(string: "C005")] + UserDefaults.standard.object(forKey: "OmnipodKit.lowPowerMonitorEnabled") as? Bool ?? false + } + + /// Candidate DASH alarm-state service UUIDs to filter on in low-power mode. + /// - `C005`: CONFIRMED 16-bit alarm 2nd-UUID on this pod (expiration reminder). Extend as more + /// alert/alarm types are captured. + /// - The 128-bit AS/AST are the RE binary model `CE1F923D-C539-48EA-7300-0A` with + /// deviceId GUESSED = pod address 179F0CF1 (TT 02=AS, 03=AST). UNCONFIRMED — no CE1F923D frame + /// has appeared in field capture; harmless if wrong (just won't match). Fix deviceId/byte-order + /// from a real [BEACON] capture before relying on these. + static let alarmServiceUUIDs: [CBUUID] = [ + CBUUID(string: "C005"), + CBUUID(string: "CE1F923D-C539-48EA-7300-0A179F0CF102"), + CBUUID(string: "CE1F923D-C539-48EA-7300-0A179F0CF103"), + ] /// Connect-request timestamps (by peripheral UUID) for measuring connect latency in didConnect. private var connectRequestedAt: [String: Date] = [:] @@ -572,8 +588,12 @@ extension BluetoothManager: CBCentralManagerDelegate { let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "-" // Tag beacon frames distinctly so the §5 diff is trivial to grep. let tag = isBeaconFrame ? "[BEACON]" : "[ADV]" - log.default("%{public}@ %{public}@ rssi=%{public}@ state=%{public}@ connectable=%{public}@ name=%{public}@ svcUUIDs=[%{public}@] mfg=%{public}@ svcData=%{public}@", - tag, peripheral.identifier.uuidString, RSSI, String(describing: peripheral.state.rawValue), + // Inter-frame delta = the advertising cadence (RE's DS-beacon-rate question). + let now = Date() + let dt = lastAdvSeen[peripheral.identifier.uuidString].map { String(format: "%.2f", now.timeIntervalSince($0)) } ?? "-" + lastAdvSeen[peripheral.identifier.uuidString] = now + log.default("%{public}@ %{public}@ dt=%{public}@s rssi=%{public}@ state=%{public}@ connectable=%{public}@ name=%{public}@ svcUUIDs=[%{public}@] mfg=%{public}@ svcData=%{public}@", + tag, peripheral.identifier.uuidString, dt, RSSI, String(describing: peripheral.state.rawValue), String(describing: connectable), name.isEmpty ? "-" : name, svcUUIDs.isEmpty ? "-" : svcUUIDs, mfg, svcData) } else if let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data, BluetoothManager.advertisementMonitorEnabled, !BluetoothManager.beaconCaptureEnabled { From 1503dfc32a3e5e7d0b7e206b5013e6d232fe292d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 2 Jul 2026 14:52:51 -0500 Subject: [PATCH 24/92] Add suppressCommands measurement mode for a clean uninterrupted idle capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loop's status polling triggered connect-on-demand every few min, and each attempt stopped the wildcard scan (and mostly timed out), so the advert-cadence / CE1F923D capture kept getting interrupted and dt reflected scan gaps, not the pod's true advertising rate. suppressCommandsEnabled (default ON, field-test only): bleRunSession fails fast without connecting, so the pod is left idle-disconnected and the wildcard scan runs continuously — a clean multi-minute window to measure the true cadence and confirm whether a CE1F923D beacon ever appears. Revert before merge. --- OmnipodKit/Bluetooth/BlePodComms.swift | 9 +++++++++ OmnipodKit/Bluetooth/BluetoothManager.swift | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index dc2e5b5..eee1958 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -690,6 +690,15 @@ class BlePodComms: PodComms { func bleRunSession(withName name: String, _ block: @escaping (_ result: SessionRunResult) -> Void) { + // Measurement mode: leave the pod fully idle-disconnected so the wildcard scan runs + // uninterrupted (no connect churn stopping it). Needed for a clean advert-cadence / CE1F923D + // capture. Field-test only — this stops ALL pod commands. Revert before merge. + if BluetoothManager.suppressCommandsEnabled { + log.default("[suppressCommands] skipping session '%{public}@' — pod left idle for measurement", name) + block(.failure(PodCommsError.podNotConnected)) + return + } + // In connect-on-demand mode the pod is normally disconnected, and self.manager (set only in // omnipodPeripheralDidConnect / on restore) is nil on a fresh launch — nothing has connected // yet. Adopt the pod's PeripheralManager from the device list (it exists while disconnected) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 5a1eb84..d85b734 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -192,6 +192,13 @@ class BluetoothManager: NSObject { UserDefaults.standard.object(forKey: "OmnipodKit.lowPowerMonitorEnabled") as? Bool ?? false } + /// Measurement mode (field-test only): skip ALL pod commands so the pod is left idle-disconnected + /// and the wildcard scan runs uninterrupted — a clean window to measure the advert cadence and + /// see whether a CE1F923D beacon ever appears, without connect churn stopping the scan. + static var suppressCommandsEnabled: Bool { + UserDefaults.standard.object(forKey: "OmnipodKit.suppressCommandsEnabled") as? Bool ?? true + } + /// Candidate DASH alarm-state service UUIDs to filter on in low-power mode. /// - `C005`: CONFIRMED 16-bit alarm 2nd-UUID on this pod (expiration reminder). Extend as more /// alert/alarm types are captured. From 16adc304452cd7882e448052e0e77e73d533d894 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 2 Jul 2026 17:20:52 -0500 Subject: [PATCH 25/92] Experiment: delayed-connect probe (CBConnectPeripheralOptionStartDelayKey) for a timed wake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testing whether a connect with StartDelay gives a timed (eventually background) wake — the periodic wake the scan path can't (stable payload coalesces). delayedConnectProbeEnabled (default ON), delayedConnectProbeSeconds (default 60): on pod discovery, stop the scan (so allowDuplicates doesn't starve it) and issue connect(options:[CBConnectPeripheralOptionStartDelayKey: N]). didConnect logs the measured delay ([delayedConnect] CONNECTED after Xs), then disconnects after 2s so the loop re-arms on the next discovery. Runs alongside suppressCommands (pod otherwise idle). Field-test only. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 54 +++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index d85b734..1950edc 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -199,6 +199,20 @@ class BluetoothManager: NSObject { UserDefaults.standard.object(forKey: "OmnipodKit.suppressCommandsEnabled") as? Bool ?? true } + /// Experiment: after each disconnect, issue a connect with CBConnectPeripheralOptionStartDelayKey + /// so iOS holds the request pending for N seconds, then completes it (the pod advertises ~1Hz, so + /// it connects ~immediately once the delay elapses). Testing whether a delayed connect gives a + /// timed background WAKE — the periodic wake the scan path can't (stable payload coalesces). Loop: + /// discover -> delayed-connect(N) -> didConnect (measure) -> brief hold -> disconnect -> repeat. + static var delayedConnectProbeEnabled: Bool { + UserDefaults.standard.object(forKey: "OmnipodKit.delayedConnectProbeEnabled") as? Bool ?? true + } + + /// Start delay (seconds) for the delayed-connect probe. Starting at 60; goal is ~300 (5 min). + static var delayedConnectProbeSeconds: Int { + (UserDefaults.standard.object(forKey: "OmnipodKit.delayedConnectProbeSeconds") as? Int) ?? 60 + } + /// Candidate DASH alarm-state service UUIDs to filter on in low-power mode. /// - `C005`: CONFIRMED 16-bit alarm 2nd-UUID on this pod (expiration reminder). Extend as more /// alert/alarm types are captured. @@ -215,6 +229,11 @@ class BluetoothManager: NSObject { /// Connect-request timestamps (by peripheral UUID) for measuring connect latency in didConnect. private var connectRequestedAt: [String: Date] = [:] + /// Delayed-connect probe: true while a StartDelay connect is in flight (issued, awaiting didConnect), + /// so didDiscover doesn't re-issue during the wait; the issue timestamp measures the true delay. + private var delayedProbeInFlight = false + private var delayedProbeIssuedAt: Date? + /// Stamp the connect time and issue the connect, so didConnect can report the latency. private func timedConnect(_ peripheral: CBPeripheral) { if connectRequestedAt[peripheral.identifier.uuidString] == nil { @@ -224,6 +243,21 @@ class BluetoothManager: NSObject { cm.connect(peripheral, options: nil) } + /// Issue a connect with CBConnectPeripheralOptionStartDelayKey and record the time, for the + /// timed-wake experiment. iOS holds the request for `delayedConnectProbeSeconds`, then connects. + private func issueDelayedConnectProbe(_ peripheral: CBPeripheral) { + guard BluetoothManager.delayedConnectProbeEnabled, !delayedProbeInFlight, + peripheral.state == .disconnected else { return } + let delay = BluetoothManager.delayedConnectProbeSeconds + // Stop the allowDuplicates scan so it doesn't starve the post-delay connect (iOS reacquires the + // pod on its own — it advertises ~1Hz). The scan resumes on the probe's disconnect. + if manager.isScanning { manager.stopScan() } + delayedProbeInFlight = true + delayedProbeIssuedAt = Date() + log.default("[delayedConnect] issuing connect with StartDelay=%{public}ds for %{public}@", delay, peripheral.identifier.uuidString) + manager.connect(peripheral, options: [CBConnectPeripheralOptionStartDelayKey: NSNumber(value: delay)]) + } + /// The keep-connected auto-reconnect. Suppressed in connect-on-demand mode, where the pod is /// left disconnected between commands (and observable via advertisements) and connected on /// demand by PeripheralManager. Explicit connects (pairing, retrieveAndConnectKnownPod, the @@ -421,6 +455,8 @@ class BluetoothManager: NSObject { String(describing: BluetoothManager.beaconCaptureEnabled), String(describing: options[CBCentralManagerScanOptionAllowDuplicatesKey] != nil)) manager.scanForPeripherals(withServices: services, options: options) + + CBConnectPeripheralOptionStartDelayKey } private func stopScanning() { @@ -610,6 +646,8 @@ extension BluetoothManager: CBCentralManagerDelegate { if isPodFrame { detectPodAlertStatus(peripheral: peripheral, advertisementData: advertisementData) + // Kick off / re-arm the delayed-connect probe once we know the pod is present + disconnected. + issueDelayedConnectProbe(peripheral) } if let podAdvertisement = PodAdvertisement(advertisementData, podType: podType) { @@ -644,6 +682,20 @@ extension BluetoothManager: CBCentralManagerDelegate { manager.stopScan() } + // Delayed-connect probe completed: report the measured delay, then disconnect after a brief + // hold so the loop re-arms. Skip the normal session proxy — this is a timing probe only. + if delayedProbeInFlight { + let measured = delayedProbeIssuedAt.map { String(format: "%.1f", Date().timeIntervalSince($0)) } ?? "?" + log.default("[delayedConnect] CONNECTED after %{public}@s (StartDelay=%{public}ds) %{public}@", + measured, BluetoothManager.delayedConnectProbeSeconds, peripheral.identifier.uuidString) + delayedProbeInFlight = false + delayedProbeIssuedAt = nil + managerQueue.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.manager.cancelPeripheralConnection(peripheral) + } + return + } + if let requestedAt = connectRequestedAt.removeValue(forKey: peripheral.identifier.uuidString) { let latency = String(format: "%.3f", Date().timeIntervalSince(requestedAt)) log.default("[#%{public}@] CONNECTED: %{public}@ — connect latency %{public}@s (known device: %{public}@)", @@ -681,6 +733,7 @@ extension BluetoothManager: CBCentralManagerDelegate { log.debug("Reconnecting disconnected autoconnect peripheral") autoReconnect(peripheral) } + delayedProbeInFlight = false // re-arm the probe on the next discovery resumeScanIfNeeded() } @@ -694,6 +747,7 @@ extension BluetoothManager: CBCentralManagerDelegate { if autoConnectIDs.contains(peripheral.identifier.uuidString) { autoReconnect(peripheral) } + delayedProbeInFlight = false // re-arm the probe on the next discovery resumeScanIfNeeded() } } From 2c679aeb242a1b8bd9290b889394cf15b50e9ad1 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 2 Jul 2026 18:25:28 -0500 Subject: [PATCH 26/92] delayed-connect probe: 5min delay, log PID, and write to Loop's persistent device log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - delayedConnectProbeSeconds default 60 -> 300 (real wake lands at StartDelay + ~40s iOS tail). - Log pid= on issue and connect (os_log) — distinguishes a background WAKE (same pid) from a State Restoration RELAUNCH (new pid), the open question of whether StartDelay can launch a terminated app. - Route the probe events to Loop's persistent device log via a new OmniConnectionDelegate .omnipodLogDeviceEvent (BlePodComms forwards to OmniPumpManager.logDeviceCommunication), so they survive background wakes / relaunch and land in the issue report even when Console isn't attached. --- OmnipodKit/Bluetooth/BlePodComms.swift | 4 ++++ OmnipodKit/Bluetooth/BluetoothManager.swift | 22 +++++++++++++++----- OmnipodKit/PumpManager/OmniPumpManager.swift | 4 ++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index eee1958..c8bad4e 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -758,6 +758,10 @@ class BlePodComms: PodComms { // MARK: - OmniConnectionDelegate extension BlePodComms: OmniConnectionDelegate { + func omnipodLogDeviceEvent(_ message: String) { + delegate?.omnipodLogDeviceEvent(message) + } + func omnipodPeripheralWasRestored(manager: PeripheralManager) { if let podState = podState, manager.peripheral.identifier.uuidString == podState.bleIdentifier { log.bleDebug("omnipodPeripheralWasRestored for %@", manager.peripheral.identifier.uuidString) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 1950edc..bb3eb6d 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -87,6 +87,13 @@ protocol OmniConnectionDelegate: AnyObject { */ func omnipodPeripheralDidFailToConnect(peripheral: CBPeripheral, error: Error?) + /// Write a message to Loop's persistent device log (survives background wakes / relaunch and is + /// bundled in the issue report, unlike a live Console stream). + func omnipodLogDeviceEvent(_ message: String) +} + +extension OmniConnectionDelegate { + func omnipodLogDeviceEvent(_ message: String) {} } @@ -208,9 +215,10 @@ class BluetoothManager: NSObject { UserDefaults.standard.object(forKey: "OmnipodKit.delayedConnectProbeEnabled") as? Bool ?? true } - /// Start delay (seconds) for the delayed-connect probe. Starting at 60; goal is ~300 (5 min). + /// Start delay (seconds) for the delayed-connect probe. Note the real wake lands at StartDelay + + /// an iOS reacquisition tail (~40s observed), so 300 → wake ~340s. static var delayedConnectProbeSeconds: Int { - (UserDefaults.standard.object(forKey: "OmnipodKit.delayedConnectProbeSeconds") as? Int) ?? 60 + (UserDefaults.standard.object(forKey: "OmnipodKit.delayedConnectProbeSeconds") as? Int) ?? 300 } /// Candidate DASH alarm-state service UUIDs to filter on in low-power mode. @@ -254,7 +262,9 @@ class BluetoothManager: NSObject { if manager.isScanning { manager.stopScan() } delayedProbeInFlight = true delayedProbeIssuedAt = Date() - log.default("[delayedConnect] issuing connect with StartDelay=%{public}ds for %{public}@", delay, peripheral.identifier.uuidString) + let pid = ProcessInfo.processInfo.processIdentifier + log.default("[delayedConnect] pid=%{public}d issuing connect with StartDelay=%{public}ds for %{public}@", pid, delay, peripheral.identifier.uuidString) + connectionDelegate?.omnipodLogDeviceEvent("[delayedConnect] pid=\(pid) issuing connect StartDelay=\(delay)s") manager.connect(peripheral, options: [CBConnectPeripheralOptionStartDelayKey: NSNumber(value: delay)]) } @@ -686,8 +696,10 @@ extension BluetoothManager: CBCentralManagerDelegate { // hold so the loop re-arms. Skip the normal session proxy — this is a timing probe only. if delayedProbeInFlight { let measured = delayedProbeIssuedAt.map { String(format: "%.1f", Date().timeIntervalSince($0)) } ?? "?" - log.default("[delayedConnect] CONNECTED after %{public}@s (StartDelay=%{public}ds) %{public}@", - measured, BluetoothManager.delayedConnectProbeSeconds, peripheral.identifier.uuidString) + let pid = ProcessInfo.processInfo.processIdentifier + log.default("[delayedConnect] pid=%{public}d CONNECTED after %{public}@s (StartDelay=%{public}ds) %{public}@", + pid, measured, BluetoothManager.delayedConnectProbeSeconds, peripheral.identifier.uuidString) + connectionDelegate?.omnipodLogDeviceEvent("[delayedConnect] pid=\(pid) CONNECTED after \(measured)s (StartDelay=\(BluetoothManager.delayedConnectProbeSeconds)s)") delayedProbeInFlight = false delayedProbeIssuedAt = nil managerQueue.asyncAfter(deadline: .now() + 2.0) { [weak self] in diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index 270a2fb..68ca7a9 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -363,6 +363,10 @@ public class OmniPumpManager: RileyLinkPumpManager { } } + func omnipodLogDeviceEvent(_ message: String) { + logDeviceCommunication(message, type: .connection) + } + func omnipodPeripheralDidConnect(manager: PeripheralManager) { logDeviceCommunication("Pod connected \(manager.peripheral.identifier.uuidString)", type: .connection) notifyPodConnectionStateDidChange(isConnected: true) From 938121df0236430fdc4ebfdb0d0e14d8e69bdaad Mon Sep 17 00:00:00 2001 From: Joe Moran Date: Thu, 2 Jul 2026 16:43:27 -0700 Subject: [PATCH 27/92] Properly handle and display times for DASH reset type pod faults --- .../OmnipodCommon/MessageBlocks/DetailedStatus.swift | 8 +++++--- OmnipodKit/PumpManagerUI/Views/PodDetailsView.swift | 12 ++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/OmnipodKit/OmnipodCommon/MessageBlocks/DetailedStatus.swift b/OmnipodKit/OmnipodCommon/MessageBlocks/DetailedStatus.swift index 1426557..88a43bf 100644 --- a/OmnipodKit/OmnipodCommon/MessageBlocks/DetailedStatus.swift +++ b/OmnipodKit/OmnipodCommon/MessageBlocks/DetailedStatus.swift @@ -59,9 +59,11 @@ struct DetailedStatus: PodInfo, Equatable { self.deliveryStatus = DeliveryStatus(rawValue: encodedData[2] & 0xf)! } - let minutesSinceActivation = encodedData[9...10].toBigEndian(UInt16.self) - if minutesSinceActivation != 0xffff { - self.faultEventTimeSinceActivation = TimeInterval(minutes: Double(minutesSinceActivation)) + let faultTime = encodedData[9...10].toBigEndian(UInt16.self) + /// For pod reset faults, this value can be $ffff (Eros or O5) and $0000 for (DASH). + /// Never return any of these dubious values as a possible valid pod fault time. + if faultTime != 0xffff && faultTime != 0 { + self.faultEventTimeSinceActivation = TimeInterval(minutes: Double(faultTime)) } else { self.faultEventTimeSinceActivation = nil } diff --git a/OmnipodKit/PumpManagerUI/Views/PodDetailsView.swift b/OmnipodKit/PumpManagerUI/Views/PodDetailsView.swift index bf9a8fa..cd92421 100644 --- a/OmnipodKit/PumpManagerUI/Views/PodDetailsView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PodDetailsView.swift @@ -78,17 +78,21 @@ struct PodDetailsView: View { } } - /// Returns the string for the number of hours pod was active + /// Returns the string for the number of hours pod was active to two decimals. This value is always + /// based on the pod's internal clock whether it is from a faulted, deactivated or currently active pod. var podHoursText: String { let podTime: TimeInterval - // If pod faulted, use the faultTime instead of podTime - if let fault = podDetails.fault, let faultTime = fault.faultEventTimeSinceActivation { + if let faultTime = podDetails.fault?.faultEventTimeSinceActivation { + /// Since the pod has a valid faultEventTimeSinceActivation, use this rather than the pod's time. + /// Reset type pod faults return atypical fault time values that will not be considered valid on decode. podTime = faultTime } else { + /// Otherwise use the always increasing podTime to compute the pod + /// hours for reset pod faults, deactivated pods, or currently active pods. podTime = podDetails.podTime } - return String(format: "%.02f", podTime.hours) + return podTime.hours.twoDecimals } var lastStatusText: String { From d3462c1d613ae907f18f8ebc82105e079e78179d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 2 Jul 2026 21:09:02 -0500 Subject: [PATCH 28/92] delayed-connect probe: self-sustaining loop (re-arm in didDisconnect, survive relaunch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loop stalled (~1h43m gap observed) because it re-armed via didDiscover: disconnect -> resume scan -> discover pod -> issue next probe. That only runs if the app stays awake after a disconnect; when iOS suspended it between cycles, the re-arm never ran and there was no pending connect left to wake on — dead until an external relaunch. - Re-arm in didDisconnect/didFailToConnect directly (issue the next StartDelay connect), leaving a pending connect that survives suspension. No dependence on the scan. - didConnect now treats ANY connect as a probe cycle while the probe is enabled (measured shows ?(restored) on a fresh process), so a restored connect after a State-Restoration relaunch also disconnects + re-arms instead of sitting connected and stalling the loop. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 31 +++++++++++++++------ 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index bb3eb6d..44289d4 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -692,10 +692,14 @@ extension BluetoothManager: CBCentralManagerDelegate { manager.stopScan() } - // Delayed-connect probe completed: report the measured delay, then disconnect after a brief - // hold so the loop re-arms. Skip the normal session proxy — this is a timing probe only. - if delayedProbeInFlight { - let measured = delayedProbeIssuedAt.map { String(format: "%.1f", Date().timeIntervalSince($0)) } ?? "?" + // Delayed-connect probe (or a restored connect after relaunch): report the delay, then + // disconnect after a brief hold so the loop re-arms (didDisconnect issues the next probe). + // Fire for ANY connect while the probe is enabled — a restored connect on a fresh process has + // delayedProbeInFlight=false but must still re-arm, else the loop stalls after every relaunch. + // Skip the normal session proxy — this is a timing probe only. (Safe here because + // suppressCommands means there are no legitimate command connects to misclassify.) + if BluetoothManager.delayedConnectProbeEnabled { + let measured = delayedProbeIssuedAt.map { String(format: "%.1f", Date().timeIntervalSince($0)) } ?? "?(restored)" let pid = ProcessInfo.processInfo.processIdentifier log.default("[delayedConnect] pid=%{public}d CONNECTED after %{public}@s (StartDelay=%{public}ds) %{public}@", pid, measured, BluetoothManager.delayedConnectProbeSeconds, peripheral.identifier.uuidString) @@ -745,8 +749,15 @@ extension BluetoothManager: CBCentralManagerDelegate { log.debug("Reconnecting disconnected autoconnect peripheral") autoReconnect(peripheral) } - delayedProbeInFlight = false // re-arm the probe on the next discovery - resumeScanIfNeeded() + delayedProbeInFlight = false + if BluetoothManager.delayedConnectProbeEnabled { + // Re-arm the delayed connect RIGHT HERE (not via a later didDiscover). This leaves a + // pending connect that survives app suspension, so the loop self-sustains — relying on + // the scan to re-issue stalled whenever iOS suspended the app between cycles. + issueDelayedConnectProbe(peripheral) + } else { + resumeScanIfNeeded() + } } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { @@ -759,7 +770,11 @@ extension BluetoothManager: CBCentralManagerDelegate { if autoConnectIDs.contains(peripheral.identifier.uuidString) { autoReconnect(peripheral) } - delayedProbeInFlight = false // re-arm the probe on the next discovery - resumeScanIfNeeded() + delayedProbeInFlight = false + if BluetoothManager.delayedConnectProbeEnabled { + issueDelayedConnectProbe(peripheral) // re-arm so a failed connect doesn't stall the loop + } else { + resumeScanIfNeeded() + } } } From 050d3f8b205559d41b64df38846e382a386823a2 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 2 Jul 2026 21:14:36 -0500 Subject: [PATCH 29/92] delayed-connect probe: everFg + app lifecycle logging to distinguish iOS relaunch from manual open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit willRestoreState + a new PID do NOT prove an iOS relaunch (both happen on a manual open too). Add the signal that does: everForeground, set true on UIApplication.didBecomeActiveNotification. A [delayedConnect] logged with everFg=false means iOS ran that process entirely in the background — proof of a wake/relaunch the user did not initiate. Also log APP FOREGROUND / APP BACKGROUND transitions (with PID) to the persistent device log for the timeline. Tag both [delayedConnect] lines with everFg=. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 33 +++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 44289d4..b4e0004 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -11,6 +11,7 @@ import CoreBluetooth import Foundation import LoopKit import os.log +import UIKit enum BluetoothManagerError: Error { case bluetoothNotAvailable(CBManagerState) @@ -242,6 +243,12 @@ class BluetoothManager: NSObject { private var delayedProbeInFlight = false private var delayedProbeIssuedAt: Date? + /// True once this PROCESS has ever been foregrounded. A [delayedConnect] with everFg=false means + /// iOS ran this process entirely in the background — proof of a background wake/relaunch the user + /// did NOT initiate (a manual open would have foregrounded it). Set on the main queue via a + /// lifecycle observer; read from managerQueue for logging (benign race for a bool). + private var everForeground = false + /// Stamp the connect time and issue the connect, so didConnect can report the latency. private func timedConnect(_ peripheral: CBPeripheral) { if connectRequestedAt[peripheral.identifier.uuidString] == nil { @@ -263,8 +270,8 @@ class BluetoothManager: NSObject { delayedProbeInFlight = true delayedProbeIssuedAt = Date() let pid = ProcessInfo.processInfo.processIdentifier - log.default("[delayedConnect] pid=%{public}d issuing connect with StartDelay=%{public}ds for %{public}@", pid, delay, peripheral.identifier.uuidString) - connectionDelegate?.omnipodLogDeviceEvent("[delayedConnect] pid=\(pid) issuing connect StartDelay=\(delay)s") + log.default("[delayedConnect] pid=%{public}d everFg=%{public}@ issuing connect with StartDelay=%{public}ds for %{public}@", pid, String(everForeground), delay, peripheral.identifier.uuidString) + connectionDelegate?.omnipodLogDeviceEvent("[delayedConnect] pid=\(pid) everFg=\(everForeground) issuing connect StartDelay=\(delay)s") manager.connect(peripheral, options: [CBConnectPeripheralOptionStartDelayKey: NSNumber(value: delay)]) } @@ -291,6 +298,22 @@ class BluetoothManager: NSObject { managerQueue.sync { self.manager = CBCentralManager(delegate: self, queue: managerQueue, options: [CBCentralManagerOptionRestoreIdentifierKey: "com.OmnipodKit"]) } + + // Track foreground/background so we can tell an iOS background wake/relaunch (everFg stays + // false) from a user-initiated open (foregrounds → everFg true). Log the transitions to the + // persistent device log with PID for the timeline. + let center = NotificationCenter.default + center.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in + self?.everForeground = true + let pid = ProcessInfo.processInfo.processIdentifier + self?.log.default("[lifecycle] pid=%{public}d APP FOREGROUND", pid) + self?.connectionDelegate?.omnipodLogDeviceEvent("[lifecycle] pid=\(pid) APP FOREGROUND") + } + center.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in + let pid = ProcessInfo.processInfo.processIdentifier + self?.log.default("[lifecycle] pid=%{public}d APP BACKGROUND", pid) + self?.connectionDelegate?.omnipodLogDeviceEvent("[lifecycle] pid=\(pid) APP BACKGROUND") + } } deinit { @@ -701,9 +724,9 @@ extension BluetoothManager: CBCentralManagerDelegate { if BluetoothManager.delayedConnectProbeEnabled { let measured = delayedProbeIssuedAt.map { String(format: "%.1f", Date().timeIntervalSince($0)) } ?? "?(restored)" let pid = ProcessInfo.processInfo.processIdentifier - log.default("[delayedConnect] pid=%{public}d CONNECTED after %{public}@s (StartDelay=%{public}ds) %{public}@", - pid, measured, BluetoothManager.delayedConnectProbeSeconds, peripheral.identifier.uuidString) - connectionDelegate?.omnipodLogDeviceEvent("[delayedConnect] pid=\(pid) CONNECTED after \(measured)s (StartDelay=\(BluetoothManager.delayedConnectProbeSeconds)s)") + log.default("[delayedConnect] pid=%{public}d everFg=%{public}@ CONNECTED after %{public}@s (StartDelay=%{public}ds) %{public}@", + pid, String(everForeground), measured, BluetoothManager.delayedConnectProbeSeconds, peripheral.identifier.uuidString) + connectionDelegate?.omnipodLogDeviceEvent("[delayedConnect] pid=\(pid) everFg=\(everForeground) CONNECTED after \(measured)s (StartDelay=\(BluetoothManager.delayedConnectProbeSeconds)s)") delayedProbeInFlight = false delayedProbeIssuedAt = nil managerQueue.asyncAfter(deadline: .now() + 2.0) { [weak self] in From 4639eda4ed3d5113f3dbec4377c7e11a73473e2c Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 3 Jul 2026 11:15:08 -0500 Subject: [PATCH 30/92] Document delayed-connect heartbeat finding; switch defaults to connect-on-demand-focus mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DASH_BEACON_FINDINGS.md: write up the background wake/heartbeat strategy — StartDelay connect + State Restoration gives a periodic background wake that survives relaunch (proven: a new PID ran everFg=false ~1h42m), with the timing distribution, gotchas (re-arm in didDisconnect, willRestoreState != relaunch, force-quit caveat), and 'off by default; only when host requests heartbeats' guidance. - Flip flags for connect-on-demand focus: suppressCommands OFF (commands run again), delayedConnectProbe OFF (no heartbeat), beaconCapture OFF (no wildcard), lowPowerMonitor ON (idle = alarm-only [C005] scan). Net: disconnected + alarm-scan while idle, connect on demand for commands — the target model, ready to measure connect-on-demand latency. --- DASH_BEACON_FINDINGS.md | 45 +++++++++++++++++++++ OmnipodKit/Bluetooth/BluetoothManager.swift | 14 +++---- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/DASH_BEACON_FINDINGS.md b/DASH_BEACON_FINDINGS.md index 75b81d4..e83028d 100644 --- a/DASH_BEACON_FINDINGS.md +++ b/DASH_BEACON_FINDINGS.md @@ -68,3 +68,48 @@ Connectionless **alert detection is achievable now**: in the scan callback, read service UUID and/or the mfg `STATUS` word and raise an alert to the pump manager when it deviates from the clear baseline (`C001` / `00020000`) — no connection required. Detail/acknowledge still needs a (slow, ~11–14 s) on-demand connect, but detection itself is instant. + +--- + +# Background wake & heartbeat: delayed connect + State Restoration + +Correction to the RE conclusion "BLE gives you alarm-wake but no periodic wake." A **periodic +background wake IS achievable** — not via scanning (the pod's disconnected advert is stable, so iOS +coalesces it), but via the **connect** path. + +## Mechanism +Issue `central.connect(peripheral, options: [CBConnectPeripheralOptionStartDelayKey: N])` while +disconnected. iOS holds the request pending for `N` seconds, then completes it (the pod advertises +~0.77 s, so it connects shortly after the delay). With State Preservation & Restoration (restore ID +`com.OmnipodKit`) the pending connect **survives app termination** and **relaunches** the app when +it completes. Loop: on each `didDisconnect`, immediately re-issue the delayed connect, so there is +always a pending connect that survives suspension — the loop self-sustains. + +## Verified on real hardware (15 h continuous run, DD5D6B83, StartDelay=300 s) +- **iOS relaunches the terminated app in the background — proven.** Tagged each connect with + `everFg` (true once the process has ever been foregrounded). Multiple **new PIDs ran with + `everFg=false` for extended periods** — e.g. one process launched via `willRestoreState` and ran + the loop **~1h42m / ~13 cycles without ever being foregrounded.** A brand-new process running + that long unforegrounded can only be iOS launching it (not a manual open — which is what + `willRestoreState` + a new PID alone would NOT prove). +- **Self-sustaining.** After moving the re-arm into `didDisconnect` (see below), the loop ran ~14 h + with no stalls across suspend / jetsam / relaunch. +- **Timing is fuzzy, not a clock.** For StartDelay=300 s: floor ~300 s, typical ~350–550 s, + frequently 600–800 s, occasional spikes to ~1100–1580 s (~26 min) under heavy iOS throttling. + Good for a "phone-home every several minutes" heartbeat; not for anything time-critical. + +## Gotchas learned +- **Re-arm in `didDisconnect`, not via `didDiscover`.** Re-arming through a later scan discovery + stalled (observed ~1h43m dead zone) whenever iOS suspended the app between cycles — the re-arm + never ran and no pending connect was left to wake on. Issuing the next delayed connect directly + in `didDisconnect` leaves a pending connect that survives suspension. +- **`willRestoreState` fires on a manual open too** — it does not, by itself, prove an iOS + relaunch. Use the `everFg`/foreground signal to distinguish. +- **Force-quit (swipe-away) disables BLE relaunch** by design — not a valid relaunch test. +- Stop the `allowDuplicates` scan before the connect (it starves connection completion); iOS + reacquires the pod on its own. + +## Intended use +Keep this **off by default.** Use the delayed-connect heartbeat only when the hosting app requests +periodic check-ins. Otherwise: stay **disconnected**, run an **alarm-filtered scan** (`[C005]`) for +instant fault wake, and **connect on demand** only to send commands / read status. diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index b4e0004..db79945 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -180,7 +180,7 @@ class BluetoothManager: NSObject { /// normal↔triggered-alert diff pins the alarm-code offsets + the stable background-filter UUID. /// Heavy (wildcard foreground scan) — field-test only; revert before merge. static var beaconCaptureEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.beaconCaptureEnabled") as? Bool ?? true + UserDefaults.standard.object(forKey: "OmnipodKit.beaconCaptureEnabled") as? Bool ?? false } /// Prefix of the DASH alarm/beacon 128-bit service UUID (per RE spec §3). @@ -193,18 +193,18 @@ class BluetoothManager: NSObject { /// Trade-off: only catches the enumerated alarm UUIDs below (currently just the one confirmed /// alert value); the clear transition isn't caught here (confirm on the next connect). See /// DASH_BEACON_FINDINGS.md. Add more alarm UUID values as they're discovered. - /// NOTE: default flipped to FALSE to run the reconciliation experiment — a clean wildcard idle - /// capture to confirm whether this pod EVER emits a 128-bit CE1F923D beacon (RE binary model) or - /// only the 16-bit C005 alarm signal we've observed. Re-enable once the true alarm UUID is confirmed. + /// Default ON for connect-on-demand mode: while idle, subscribe only for alarm adverts (`[C005]`), + /// so we get a background fault wake but don't wake on every normal advert. Connect-on-demand + /// (its own light helper scan) handles command connects. static var lowPowerMonitorEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.lowPowerMonitorEnabled") as? Bool ?? false + UserDefaults.standard.object(forKey: "OmnipodKit.lowPowerMonitorEnabled") as? Bool ?? true } /// Measurement mode (field-test only): skip ALL pod commands so the pod is left idle-disconnected /// and the wildcard scan runs uninterrupted — a clean window to measure the advert cadence and /// see whether a CE1F923D beacon ever appears, without connect churn stopping the scan. static var suppressCommandsEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.suppressCommandsEnabled") as? Bool ?? true + UserDefaults.standard.object(forKey: "OmnipodKit.suppressCommandsEnabled") as? Bool ?? false } /// Experiment: after each disconnect, issue a connect with CBConnectPeripheralOptionStartDelayKey @@ -213,7 +213,7 @@ class BluetoothManager: NSObject { /// timed background WAKE — the periodic wake the scan path can't (stable payload coalesces). Loop: /// discover -> delayed-connect(N) -> didConnect (measure) -> brief hold -> disconnect -> repeat. static var delayedConnectProbeEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.delayedConnectProbeEnabled") as? Bool ?? true + UserDefaults.standard.object(forKey: "OmnipodKit.delayedConnectProbeEnabled") as? Bool ?? false } /// Start delay (seconds) for the delayed-connect probe. Note the real wake lands at StartDelay + From e6d9587fbf7e9929f2253e3479db42b2b2808484 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 3 Jul 2026 11:25:52 -0500 Subject: [PATCH 31/92] Gate the delayed-connect heartbeat on setMustProvideBLEHeartbeat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delayed-connect loop is the pump-provided heartbeat, used only when the CGM can't provide one (network CGM). Drive it from PumpManager.setMustProvideBLEHeartbeat instead of a static flag: - OmniPumpManager.setMustProvideBLEHeartbeat -> (podComms as? BlePodComms).setProvidesHeartbeat -> BluetoothManager.setProvidesHeartbeat, which sets runtime heartbeatEnabled and kicks off / tears down the loop. - delayedConnectProbeActive = heartbeatEnabled || ; probe/re-arm sites use it. - didConnect only treats a connect as a probe when it's a genuine in-flight probe, or in suppressCommands test mode — so a real command connect is never hijacked now that commands run alongside. Normal mode: CGM heartbeats -> pump stays disconnected + alarm-scan, connect on demand. --- OmnipodKit/Bluetooth/BlePodComms.swift | 6 ++ OmnipodKit/Bluetooth/BluetoothManager.swift | 60 ++++++++++++++++---- OmnipodKit/PumpManager/OmniPumpManager.swift | 4 ++ 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index c8bad4e..d65ea30 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -55,6 +55,12 @@ class BlePodComms: PodComms { super.forgetPod() } + /// Enable/disable the pump-provided BLE heartbeat (delayed-connect loop). Driven by + /// OmniPumpManager.setMustProvideBLEHeartbeat — used only when the CGM can't provide a heartbeat. + func setProvidesHeartbeat(_ enabled: Bool) { + bluetoothManager?.setProvidesHeartbeat(enabled) + } + // Removes references to the bluetoothManager to avoid future // "Bluetooth use unsupported on this device" errors on the // next BlePodComms instantiation and subsequent usage. diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index db79945..fe74d1a 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -249,6 +249,44 @@ class BluetoothManager: NSObject { /// lifecycle observer; read from managerQueue for logging (benign race for a bool). private var everForeground = false + /// Runtime heartbeat request (from PumpManager.setMustProvideBLEHeartbeat via BlePodComms). When + /// true, run the delayed-connect loop so the pump provides periodic background wakes — used only + /// when the CGM can't (network CGM). Normally false: stay disconnected + alarm-scan, connect on + /// demand. managerQueue-isolated. + private var heartbeatEnabled = false + + /// The delayed-connect loop is active when the host requests a heartbeat OR the manual test flag + /// is set. Isolated to managerQueue (all probe call sites run there). + private var delayedConnectProbeActive: Bool { + heartbeatEnabled || BluetoothManager.delayedConnectProbeEnabled + } + + /// Enable/disable the pump-provided heartbeat (delayed-connect loop). Driven by + /// PumpManager.setMustProvideBLEHeartbeat. + func setProvidesHeartbeat(_ enabled: Bool) { + managerQueue.async { + guard self.heartbeatEnabled != enabled else { return } + self.heartbeatEnabled = enabled + let pid = ProcessInfo.processInfo.processIdentifier + self.log.default("[heartbeat] pid=%{public}d providesHeartbeat=%{public}@", pid, String(enabled)) + self.connectionDelegate?.omnipodLogDeviceEvent("[heartbeat] pid=\(pid) providesHeartbeat=\(enabled)") + if enabled { + // Kick off the delayed-connect loop against the known pod (prefer an autoconnect one). + let device = self.devices.first(where: { self.autoConnectIDs.contains($0.manager.peripheral.identifier.uuidString) }) ?? self.devices.first + if let peripheral = device?.manager.peripheral { + self.issueDelayedConnectProbe(peripheral) + } + } else { + // Stop the loop; fall back to connect-on-demand + alarm scan. + self.delayedProbeInFlight = false + for device in self.devices where device.manager.peripheral.state != .disconnected { + self.manager.cancelPeripheralConnection(device.manager.peripheral) + } + self.resumeScanIfNeeded() + } + } + } + /// Stamp the connect time and issue the connect, so didConnect can report the latency. private func timedConnect(_ peripheral: CBPeripheral) { if connectRequestedAt[peripheral.identifier.uuidString] == nil { @@ -261,7 +299,7 @@ class BluetoothManager: NSObject { /// Issue a connect with CBConnectPeripheralOptionStartDelayKey and record the time, for the /// timed-wake experiment. iOS holds the request for `delayedConnectProbeSeconds`, then connects. private func issueDelayedConnectProbe(_ peripheral: CBPeripheral) { - guard BluetoothManager.delayedConnectProbeEnabled, !delayedProbeInFlight, + guard delayedConnectProbeActive, !delayedProbeInFlight, peripheral.state == .disconnected else { return } let delay = BluetoothManager.delayedConnectProbeSeconds // Stop the allowDuplicates scan so it doesn't starve the post-delay connect (iOS reacquires the @@ -715,13 +753,15 @@ extension BluetoothManager: CBCentralManagerDelegate { manager.stopScan() } - // Delayed-connect probe (or a restored connect after relaunch): report the delay, then - // disconnect after a brief hold so the loop re-arms (didDisconnect issues the next probe). - // Fire for ANY connect while the probe is enabled — a restored connect on a fresh process has - // delayedProbeInFlight=false but must still re-arm, else the loop stalls after every relaunch. - // Skip the normal session proxy — this is a timing probe only. (Safe here because - // suppressCommands means there are no legitimate command connects to misclassify.) - if BluetoothManager.delayedConnectProbeEnabled { + // Delayed-connect probe: report the delay, then disconnect after a brief hold so the loop + // re-arms (didDisconnect issues the next probe). Skip the normal session proxy — timing only. + // - delayedProbeInFlight: a connect WE issued as a probe. + // - suppressCommands + active: test mode has no real command connects, so any connect + // (incl. a restored one after relaunch) is a probe and must re-arm. + // In normal (command) mode we must NOT hijack a real command connect, so only genuine + // in-flight probes qualify. + let treatAsProbe = delayedProbeInFlight || (delayedConnectProbeActive && BluetoothManager.suppressCommandsEnabled) + if treatAsProbe { let measured = delayedProbeIssuedAt.map { String(format: "%.1f", Date().timeIntervalSince($0)) } ?? "?(restored)" let pid = ProcessInfo.processInfo.processIdentifier log.default("[delayedConnect] pid=%{public}d everFg=%{public}@ CONNECTED after %{public}@s (StartDelay=%{public}ds) %{public}@", @@ -773,7 +813,7 @@ extension BluetoothManager: CBCentralManagerDelegate { autoReconnect(peripheral) } delayedProbeInFlight = false - if BluetoothManager.delayedConnectProbeEnabled { + if delayedConnectProbeActive { // Re-arm the delayed connect RIGHT HERE (not via a later didDiscover). This leaves a // pending connect that survives app suspension, so the loop self-sustains — relying on // the scan to re-issue stalled whenever iOS suspended the app between cycles. @@ -794,7 +834,7 @@ extension BluetoothManager: CBCentralManagerDelegate { autoReconnect(peripheral) } delayedProbeInFlight = false - if BluetoothManager.delayedConnectProbeEnabled { + if delayedConnectProbeActive { issueDelayedConnectProbe(peripheral) // re-arm so a failed connect doesn't stall the loop } else { resumeScanIfNeeded() diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index 68ca7a9..f41e57a 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -319,6 +319,10 @@ public class OmniPumpManager: RileyLinkPumpManager { rileyLinkDeviceProvider.timerTickEnabled = self.state.isPumpDataStale || mustProvideBLEHeartbeat } else { provideHeartbeat = mustProvideBLEHeartbeat + // BLE pod: when the host needs us to provide the heartbeat (e.g. a CGM that can't), run + // the delayed-connect loop for periodic background wakes; otherwise stay disconnected + + // alarm-scan and connect on demand. + (podComms as? BlePodComms)?.setProvidesHeartbeat(mustProvideBLEHeartbeat) } } From 237930f5d5f5c07eb7801a17639fee88ff1c8e39 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 3 Jul 2026 12:41:58 -0500 Subject: [PATCH 32/92] Log setMustProvideBLEHeartbeat at the call site provideHeartbeat isn't persisted, so reading it elsewhere can be stale relative to the setter. Log the requested value + PID directly in setMustProvideBLEHeartbeat so the device log captures exactly what Loop requests and when. --- OmnipodKit/PumpManager/OmniPumpManager.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index f41e57a..e1648a7 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -315,6 +315,10 @@ public class OmniPumpManager: RileyLinkPumpManager { } public func setMustProvideBLEHeartbeat(_ mustProvideBLEHeartbeat: Bool) { + // Log at the call site so we capture exactly what Loop requests and when — provideHeartbeat + // isn't persisted, so reading it elsewhere can be stale relative to this call. + let pid = ProcessInfo.processInfo.processIdentifier + logDeviceCommunication("[heartbeat] pid=\(pid) setMustProvideBLEHeartbeat(\(mustProvideBLEHeartbeat))", type: .connection) if self.state.podType.usesRileyLink { rileyLinkDeviceProvider.timerTickEnabled = self.state.isPumpDataStale || mustProvideBLEHeartbeat } else { From e336b94bcdb82495b3cb79f26943c60e5587d01d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 3 Jul 2026 13:02:39 -0500 Subject: [PATCH 33/92] connect-on-demand: go fully dark for the connect (helper scan was starving it) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The (c) helper scan (non-allowDuplicates wildcard during the connect) reliably TIMED OUT at 20s even foreground — any concurrent scan starves connection completion on iOS, not just the allowDuplicates flood. The delayed-connect experiments completed precisely because they went dark. So connectOnDemand now just stops scanning and lets iOS complete the connect; the alarm scan resumes on disconnect. Measure the bare no-scan latency next; optimize (fresh-discovery / foreground pre-connect) from there. --- OmnipodKit/Bluetooth/PeripheralManager.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index 12191ab..e1957c2 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -358,13 +358,12 @@ extension PeripheralManager { func connectOnDemand(timeout: TimeInterval) throws { guard peripheral.state != .connected else { return } log.default("[connectOnDemand] connecting on demand (state=%{public}d, timeout=%{public}ds)", peripheral.state.rawValue, Int(timeout)) - // Going fully dark makes iOS fall back to its sparse connection duty cycle (~11-14s to - // connect). Instead: drop the heavy allowDuplicates monitor scan, then run a LIGHT scan - // (non-allowDuplicates) so iOS actively hears the pod (~1Hz) and completes the connect fast. - // It's the allowDuplicates flood — not scanning per se — that starved the connect earlier. - // BluetoothManager stops this helper scan on didConnect and restores the monitor on disconnect. + // Go fully dark for the connect: ANY concurrent scan (even non-allowDuplicates wildcard) + // starves connection completion on iOS — connect-on-demand with a "helper" scan reliably + // TIMED OUT at 20s foreground, whereas the delayed-connect experiments (no scan) always + // completed. So stop scanning and let iOS complete the connect; the alarm scan resumes on + // disconnect. (Latency without a scan is iOS's own reacquisition; measure it, optimize next.) central?.stopScan() - central?.scanForPeripherals(withServices: nil, options: nil) let start = Date() do { try runCommand(timeout: timeout, allowDisconnected: true) { From ad82b5f157891cc94a0735fcd93e368da4699056 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 3 Jul 2026 13:11:21 -0500 Subject: [PATCH 34/92] connect-on-demand: fresh-discovery connect to cut latency (~16s -> ~1-2s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare connect() on a non-scanned pod waits out iOS's cold reacquisition (~16s foreground, saw a 20s timeout too), because while idle we only scan for the alarm UUID [C005] so the pod is never 'fresh' to iOS. Fix: connectViaFreshDiscovery scans for the pod's service UUID, and on the next didDiscover stops the scan and connects on that just-heard advertisement — no concurrent scan to starve it (stop happens before connect). 4s fallback to a direct cold connect if we don't hear it. - BluetoothManager: podScanServiceUUID property, connectViaFreshDiscovery, pendingFreshConnectID handling in didDiscover, and set PeripheralManager.bluetoothManager on creation. - PeripheralManager: weak bluetoothManager; connectOnDemand routes through it (direct-connect fallback if unavailable). --- OmnipodKit/Bluetooth/BluetoothManager.swift | 51 +++++++++++++++++--- OmnipodKit/Bluetooth/PeripheralManager.swift | 11 ++++- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index fe74d1a..a3e2854 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -371,7 +371,9 @@ class BluetoothManager: NSObject { device.advertisement = podAdvertisement } } else { - device = Omni(peripheralManager: PeripheralManager(peripheral: peripheral, podType: podType, centralManager: manager), advertisement: podAdvertisement) + let pm = PeripheralManager(peripheral: peripheral, podType: podType, centralManager: manager) + pm.bluetoothManager = self // for fresh-discovery connect-on-demand + device = Omni(peripheralManager: pm, advertisement: podAdvertisement) devices.append(device) log.info("Created device") } @@ -495,14 +497,41 @@ class BluetoothManager: NSObject { completion(nil) } - private func startScanning() { - let serviceUUID: CBUUID + /// The service UUID the pod advertises when healthy-disconnected (used to discover it for a fast + /// connect). O5 switches to a pdmId-based UUID after pairing. + private var podScanServiceUUID: CBUUID { if podType.isO5, let pdmId = uuidPdmId { - // The O5 service advertisement UUID is now using the pdmId - serviceUUID = o5ServiceAdvertisementUUID(pdmId) - } else { - serviceUUID = podType.blePodProfile.advertisementServiceUUID + return o5ServiceAdvertisementUUID(pdmId) } + return podType.blePodProfile.advertisementServiceUUID + } + + /// Peripheral awaiting a fresh-discovery connect: while set, the next matching didDiscover stops + /// the scan and connects on that just-heard advertisement (fast) instead of a cold reacquisition. + private var pendingFreshConnectID: String? + + /// Connect fast by first hearing the pod: scan for its service, and on the next discovery stop the + /// scan and connect on that fresh advertisement (~1-2s) rather than a bare cold connect (~16s). + /// Falls back to a direct connect if we don't hear it quickly. + func connectViaFreshDiscovery(_ peripheral: CBPeripheral) { + managerQueue.async { + let id = peripheral.identifier.uuidString + self.pendingFreshConnectID = id + self.manager.stopScan() + self.manager.scanForPeripherals(withServices: [self.podScanServiceUUID], options: nil) + self.log.default("[connectOnDemand] fresh-discovery scan for %{public}@", id) + self.managerQueue.asyncAfter(deadline: .now() + 4.0) { [weak self] in + guard let self = self, self.pendingFreshConnectID == id else { return } + self.pendingFreshConnectID = nil + self.log.default("[connectOnDemand] no fresh discovery in 4s — direct (cold) connect") + self.manager.stopScan() + self.manager.connect(peripheral, options: nil) + } + } + } + + private func startScanning() { + let serviceUUID: CBUUID = podScanServiceUUID let services: [CBUUID]? let options: [String: Any] if BluetoothManager.lowPowerMonitorEnabled { @@ -717,6 +746,14 @@ extension BluetoothManager: CBCentralManagerDelegate { if isPodFrame { detectPodAlertStatus(peripheral: peripheral, advertisementData: advertisementData) + // Fresh-discovery connect: we just heard the pod — stop scanning and connect NOW on this + // fresh advertisement (fast) instead of waiting out iOS's cold reacquisition (~16s). + if pendingFreshConnectID == peripheral.identifier.uuidString { + pendingFreshConnectID = nil + log.default("[connectOnDemand] fresh discovery -> connect %{public}@", peripheral.identifier.uuidString) + manager.stopScan() + manager.connect(peripheral, options: nil) + } // Kick off / re-arm the delayed-connect probe once we know the pod is present + disconnected. issueDelayedConnectProbe(peripheral) } diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index e1957c2..10fbe01 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -69,6 +69,9 @@ class PeripheralManager: NSObject { private(set) weak var central: CBCentralManager? + /// Owning BluetoothManager, for the fresh-discovery connect-on-demand path (it sees didDiscover). + weak var bluetoothManager: BluetoothManager? + let profile: BlePodProfile let configuration: Configuration @@ -368,7 +371,13 @@ extension PeripheralManager { do { try runCommand(timeout: timeout, allowDisconnected: true) { addCondition(.connect) - central?.connect(peripheral, options: nil) + // Fast path: let BluetoothManager hear the pod first, then connect on the fresh advert + // (~1-2s) instead of a cold connect (~16s). Falls back to a direct connect if unavailable. + if let bt = bluetoothManager { + bt.connectViaFreshDiscovery(peripheral) + } else { + central?.connect(peripheral, options: nil) + } } } catch { // Unstick a connect that never completed, so didDisconnect/didFailToConnect fires From 24c3a90ce31a226b4c18a8be24d43d4ace2a1006 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 3 Jul 2026 13:15:18 -0500 Subject: [PATCH 35/92] Add poddev.md handoff summary for a fresh agent Summarizes the connect-on-demand + fault-via-advertisement work: environment/build/install/push workflow, the target architecture (disconnected + alarm-scan idle, connect-on-demand, heartbeat via setMustProvideBLEHeartbeat), field-test flags + current defaults, key findings (16-bit alert signal, no CE1F923D beacon, delayed-connect+State-Restoration relaunch proof, connect-on-demand latency + fresh-discovery fix), key files/functions, open items, gotchas, and revert-before-merge list. --- poddev.md | 218 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 poddev.md diff --git a/poddev.md b/poddev.md new file mode 100644 index 0000000..5421cf2 --- /dev/null +++ b/poddev.md @@ -0,0 +1,218 @@ +# poddev.md — OmnipodKit "connect-on-demand + fault-via-advertisement" work + +Handoff for a fresh agent. This is a **DIY LibreLoop / LoopKit** OmnipodKit (Dash) effort exploring +a **normally-disconnected** pump connectivity model: stay disconnected, wake on faults via BLE +advertisement, connect on demand for commands, and provide a periodic heartbeat only when asked. + +Everything here lives on branch **`unsolicited-fault-listener-prototype`** of +**`loopkitdev/OmnipodKit`**. It is a **prototype / field-test branch** — heavily instrumented, +several default-ON test flags and debug scaffolding that MUST be reverted before any merge. + +--- + +## Environment & workflow + +- **Workspace:** `~/loopdev/LoopWorkspace` (the superproject; OmnipodKit is the submodule of + interest). NOTE: earlier sessions used `~/loopdev/LoopWorkspace-test`; the main workspace going + forward is `~/loopdev/LoopWorkspace`. +- **Repo of interest:** `LoopWorkspace/OmnipodKit`, branch `unsolicited-fault-listener-prototype`. +- **Commits:** author email `pschwamb@gmail.com`; **plain messages, NO `Co-Authored-By` trailers.** +- **Push (loopkitdev fork):** default `gh` account is pull-only; switch first: + ```bash + gh auth switch --user loopkitdev + git -c user.email=pschwamb@gmail.com commit -m "…" + git push "https://loopkitdev:$(gh auth token)@github.com/loopkitdev/OmnipodKit.git" HEAD:unsolicited-fault-listener-prototype + ``` + +### Build + install (real device — a real Dash pod is paired) +```bash +cd ~/loopdev/LoopWorkspace +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild \ + -workspace LoopWorkspace.xcworkspace -scheme LoopWorkspace \ + -destination 'platform=iOS,id=00008140-001C29C93660801C' \ + -derivedDataPath /tmp/dd-device -allowProvisioningUpdates \ + LOOP_DEVELOPMENT_TEAM=UY678SP37Q build +# then install (device may show "unavailable" if locked — unlock/reconnect): +xcrun devicectl device install app --device 4950044E-6D03-564F-A1D9-E86E77D99613 \ + /tmp/dd-device/Build/Products/Debug-iphoneos/Loop.app +``` +- **Device:** iPhone, xcodebuild id `00008140-001C29C93660801C`, devicectl UUID + `4950044E-6D03-564F-A1D9-E86E77D99613`. Pod `DD5D6B83-1B76-FE80-C2A9-0BE91CC43225`, address + `0x179F0CF1`. +- **Do NOT wrap `devicectl` in `timeout`** — not installed on macOS; it silently no-ops (rc=0) and + you keep running the old build. +- **Force-quit + reopen Loop after each install.** Do NOT uninstall/delete the app — that loses pod + pairing state (only one pod, precious). +- **Logs:** the persistent **device log** (Loop → Settings → Issue Report) captures our tagged lines + and survives background wakes — preferred over a live `log stream`. Subsystem + `com.loopkit.OmnipodKit`. + +### Log tags to grep +`[connectOnDemand]` `[delayedConnect]` `[heartbeat]` `[lifecycle]` `[POD-STATUS]` `[POD-ALERT]` +`[ADV]` `[BEACON]` `[SCAN]` + +--- + +## The target architecture + +1. **Idle (normal):** pump **disconnected**, running an **alarm-filtered scan** (`[C005]` + a + speculative 128-bit UUID) so iOS wakes us — even via State Restoration — only on a **fault + advert**. No connection held. +2. **Command / status:** **connect on demand** (fast, via fresh-discovery), run, then idle-disconnect + (~4 s after the session queue empties). +3. **Heartbeat (only when the CGM can't provide one — e.g. a network CGM):** Loop calls + `setMustProvideBLEHeartbeat(true)` → the **delayed-connect loop** provides a periodic background + wake. Normally OFF (a BLE CGM provides the heartbeat, so the pump just listens for alarms). + +--- + +## Field-test flags (in `BluetoothManager.swift`, UserDefaults-backed) — CURRENT defaults + +| flag | default | meaning | +|---|---|---| +| `advertisementMonitorEnabled` | true | log `[ADV]`/`[SCAN]` for pod frames | +| `connectOnDemandEnabled` | true | normally-disconnected + connect per command | +| `lowPowerMonitorEnabled` | **true** | idle scan = alarm UUIDs only (`alarmServiceUUIDs`) | +| `beaconCaptureEnabled` | false | §5 wildcard capture (`withServices:nil`, allowDuplicates) | +| `suppressCommandsEnabled` | false | measurement mode: skip ALL commands (leave pod idle) | +| `delayedConnectProbeEnabled` | false | manual override for the heartbeat loop (normally driven by `setMustProvideBLEHeartbeat`) | +| `delayedConnectProbeSeconds` | 300 | StartDelay for the heartbeat probe | + +`alarmServiceUUIDs = [C005, CE1F923D-…-0A179F0CF102 (AS), …03 (AST)]` — the 16-bit `C005` is +CONFIRMED; the two 128-bit are the RE model with **deviceId GUESSED = 179F0CF1, UNCONFIRMED** +(this pod never emitted a CE1F923D frame — see findings). + +--- + +## Key findings (also in `DASH_BEACON_FINDINGS.md`) + +### 1. Alerts ride the NORMAL 16-bit advertisement — connectionless detection works +Triggering a non-fault alert (expiration reminder) flipped, in the normal connectable advert: +- 2nd service UUID: `C001` (clear) ↔ **`C005`** (alert) +- mfg status word: `…000a`**`00020000`**`f10c` (clear) → `…000a`**`000a0008`**`f10c` (alert) + +Reversible on acknowledge. `detectPodAlertStatus()` reads this from the scan callback and logs +`[POD-STATUS]` / `[POD-ALERT]` — **no connection needed.** (Not yet wired to Loop's real alert +system — stage 2, pending more alarm-code samples.) + +### 2. This pod does NOT emit the 128-bit `CE1F923D` beacon +The RE binary model predicts a 128-bit `CE1F923D-C539-48EA-7300-0A` beacon +(TT 00/01=DS, 02/03=AS/AST). A clean **10-minute idle wildcard capture** never produced one. This +pod advertises the **16-bit** list at **~0.77 s**, **stable** payload, **connectable**. So on this +hardware the alarm lever is the 16-bit `C005` / mfg word, not `CE1F923D`. The `CE1F923D` path is +presumed **fault-only** (or a different pod gen) — **untested** (needs a sacrificed pod). + +### 3. Delayed connect + State Restoration = periodic background wake that SURVIVES relaunch +`central.connect(peripheral, options:[CBConnectPeripheralOptionStartDelayKey: N])` while +disconnected: iOS holds it N s, then completes it, and via State Restoration (restore id +`com.OmnipodKit`) **relaunches the terminated app**. **Proven** over a 15 h run: new PIDs ran with +`everFg=false` (never foregrounded) for up to **~1h42m** — iOS launched them, not the user. +- Self-sustaining: re-arm the next probe in `didDisconnect` (relying on `didDiscover` stalled when + suspended). +- Timing is **fuzzy**: StartDelay=300 → actual wakes ~5–12 min typical, spikes to ~26 min. +- Gated by `setMustProvideBLEHeartbeat` (see wiring below). Off by default. +- `everFg` (ever-foregrounded) is the signal that distinguishes an iOS relaunch from a manual open — + `willRestoreState` + a new PID do NOT prove a relaunch (both happen on a manual open too). + +### 4. Connect-on-demand latency — the current focus +- A bare `connect()` on a non-scanned pod = **~16 s** cold reacquisition (saw a 20 s timeout too), + because idle we only scan `[C005]` so the pod is never "fresh" to iOS. +- **Any concurrent scan starves the connect** (even non-allowDuplicates wildcard) → 20 s timeout. + So the connect must go **fully dark**, OR use fresh-discovery. +- **Fresh-discovery connect (JUST INSTALLED, commit `ad82b5f`, awaiting measurement):** + `connectViaFreshDiscovery` scans for the pod's service `[4024]`, and on the next `didDiscover` + **stops the scan then connects** on that just-heard advert (~1–2 s expected), 4 s fallback to a + cold connect. **Next step: confirm the `[connectOnDemand] connected in X.XXXs` number.** + +--- + +## Heartbeat wiring (setMustProvideBLEHeartbeat → delayed-connect loop) + +``` +OmniPumpManager.setMustProvideBLEHeartbeat(b) // logs [heartbeat] setMustProvideBLEHeartbeat(b) at call site + → (podComms as? BlePodComms).setProvidesHeartbeat(b) + → BluetoothManager.setProvidesHeartbeat(b) // heartbeatEnabled = b; kick off / tear down loop +``` +`delayedConnectProbeActive = heartbeatEnabled || `. `didConnect` only treats a +connect as a probe when it's a genuine in-flight probe (or `suppressCommands` test mode), so a real +command connect is never hijacked now that commands run alongside. + +--- + +## Key files & functions + +- **`OmnipodKit/Bluetooth/BluetoothManager.swift`** — the hub. Flags; `startScanning` (mode select: + lowPower alarm / beacon wildcard / monitor); `podScanServiceUUID`; `connectViaFreshDiscovery` + + `pendingFreshConnectID` (handled in `didDiscover`); `detectPodAlertStatus` + + `lastPodStatusWord`/`podStatusClear`/`podStatusWord`; `issueDelayedConnectProbe` + the `didConnect` + probe block + re-arm in `didDisconnect`/`didFailToConnect`; `setProvidesHeartbeat` + `heartbeatEnabled` + + `delayedConnectProbeActive`; `everForeground` + lifecycle observers (`[lifecycle]`); + `resumeScanIfNeeded`; `alarmServiceUUIDs`; `peripheralManager(forIdentifier:)`. +- **`OmnipodKit/Bluetooth/PeripheralManager.swift`** — `connectOnDemand` (routes through + `bluetoothManager.connectViaFreshDiscovery`, direct-connect fallback); `runCommand(allowDisconnected:)` + (lets the connect itself run from disconnected); `weak var bluetoothManager`. +- **`OmnipodKit/Bluetooth/BlePodComms.swift`** — `bleRunSession` (adopts the PeripheralManager while + disconnected; `suppressCommands` early-out); `setProvidesHeartbeat` passthrough; `omnipodLogDeviceEvent`. +- **`OmnipodKit/PumpManager/OmniPumpManager.swift`** — `setMustProvideBLEHeartbeat` (call-site log + + `BlePodComms.setProvidesHeartbeat`); `logDeviceCommunication`; `omnipodLogDeviceEvent`; + `triggerTestAlert` (DEBUG); `omnipodPeripheralDidConnect/Disconnect`. +- **`OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift`** — DEBUG "Trigger Test Alert" button + (fires an expiration-reminder alert ~60 s out; overwrites the pod's expiration reminder — reset in + settings after). +- **`OmnipodKit/DASH_BEACON_FINDINGS.md`** — the findings writeup (alerts + heartbeat). + +--- + +## Open items / next steps + +1. **Measure fresh-discovery connect latency** (`ad82b5f`). If still slow / falling back to cold + connect, tune the discovery window or the scan filter. +2. **Foreground pre-connect** (user's idea): connect on `didBecomeActive` so the pod is up by the + time the user acts. Nice-to-have if fresh-discovery is fast; the real fix if not. +3. **Wire `[POD-ALERT]` into Loop's real alert path** (stage 2) — needs the per-alert bit mapping + (only `C005`/expiration-reminder sampled so far; trigger other alert types to enumerate). +4. **On a heartbeat wake, actually fire the heartbeat** — `issueHeartbeatIfNeeded()` / + `pumpManagerBLEHeartbeatDidFire` is not yet called from the delayed-connect `didConnect`. +5. **`willRestoreState` explicit re-arm** for the heartbeat (currently self-sustains via the + `didDisconnect` re-arm + Loop re-calling `setMustProvideBLEHeartbeat`). +6. **Fault / `CE1F923D`** path — only resolvable with a pod you're willing to fault. + +--- + +## Gotchas / hard-won lessons + +- **Scanning during a connect starves it** (even non-allowDuplicates wildcard). Stop the scan before + connecting; for speed, scan → single discovery → stop → connect. +- **Force-quit (swipe-away) disables BLE relaunch** by design — not a valid relaunch test. +- **`willRestoreState` fires on a manual open too** — use `everFg` to prove an iOS relaunch. +- The pod is known via `retrievePeripherals` recovery even when not scan-discovered, so + connect-on-demand works while the idle scan is filtered to `[C005]`. +- **`SN0.0=` periodic-status registration is inert** — the pod ACKs the write but never pushes; a + connected Dash link doesn't originate frames. Leftover from the earliest experiment; ignore. +- `provideHeartbeat` is not persisted — log at the `setMustProvideBLEHeartbeat` call site, not later. + +## MUST revert before any merge +All field-test scaffolding: the default-ON flags, `[ADV]/[SCAN]/[POD-*]/[delayedConnect]/[heartbeat]/[lifecycle]` +logging, the DEBUG Trigger-Test-Alert button + `triggerTestAlert`, `suppressCommands`, the +delayed-connect probe scaffolding, the speculative 128-bit `alarmServiceUUIDs` (unconfirmed +deviceId), and the instance-lifecycle INIT/DEINIT stack-trace logging. + +## Recent commits (newest first) +``` +ad82b5f connect-on-demand: fresh-discovery connect (~16s -> ~1-2s) <-- HEAD, awaiting measurement +e336b94 connect-on-demand: go fully dark for the connect (helper scan starved it) +237930f Log setMustProvideBLEHeartbeat at the call site +e6d9587 Gate the delayed-connect heartbeat on setMustProvideBLEHeartbeat +4639eda Document heartbeat finding; switch defaults to connect-on-demand-focus mode +050d3f8 everFg + app lifecycle logging (iOS relaunch vs manual open) +d3462c1 delayed-connect probe: self-sustaining loop (re-arm in didDisconnect) +2c679ae delayed-connect probe: 5min delay, PID, persistent device log +16adc30 Experiment: delayed-connect probe (CBConnectPeripheralOptionStartDelayKey) +1503dfc suppressCommands measurement mode +feeb7a7 Reconciliation capture: wildcard idle + advert cadence + candidate 128-bit UUIDs +91a5158 Low-power fault-watch (option 3): alarm-UUID scan +6c72acf DASH alert findings writeup + connectionless alert detector +4694f59 DEBUG Trigger-Test-Alert (fire a non-fault alert for §5) +815f65e / ad2e375 / 5e38237 / 13a96fa connect-on-demand plumbing fixes +eb630f7 §5 beacon-capture mode +``` From 2812cb2bce549b12594f57ff166a0131f35928d0 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 3 Jul 2026 13:50:15 -0500 Subject: [PATCH 36/92] connect-on-demand: defer fresh-discovery connect a tick after stopScan Fresh discovery found the pod fast (~1-2s) but the connect issued synchronously inside didDiscover wedged in .connecting and timed out at 20s -- the scan hadn't finished tearing down, starving the connect. Hop to the next managerQueue tick so stopScan settles before connecting. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index a3e2854..89a6f21 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -752,7 +752,13 @@ extension BluetoothManager: CBCentralManagerDelegate { pendingFreshConnectID = nil log.default("[connectOnDemand] fresh discovery -> connect %{public}@", peripheral.identifier.uuidString) manager.stopScan() - manager.connect(peripheral, options: nil) + // Defer the connect one managerQueue tick so the scan actually tears down first. + // Connecting synchronously here (still inside the scan's didDiscover) starved the + // connect -> it wedged in .connecting and timed out at 20s. Let iOS settle the + // stopScan, then connect fully dark on the just-heard advert. + managerQueue.async { [weak self] in + self?.manager.connect(peripheral, options: nil) + } } // Kick off / re-arm the delayed-connect probe once we know the pod is present + disconnected. issueDelayedConnectProbe(peripheral) From fbbcbfeaa479e4cee33f4c46739316359af070aa Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 3 Jul 2026 14:34:41 -0500 Subject: [PATCH 37/92] connect-on-demand: flush stale connection state before reconnect First connect on a peripheral is clean (~1.7s with the deferred fresh connect), but every reconnect after an idle-disconnect wedged in .connecting for the full 20s -- the cached CBPeripheral carries stale iOS-side connection intent even though it reports .disconnected and the pod is advertising connectable. freshConnect() cancels any lingering connection and re-fetches the peripheral before connect(), used on both the fresh-discovery and cold-fallback paths. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 89a6f21..3ec48db 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -525,11 +525,27 @@ class BluetoothManager: NSObject { self.pendingFreshConnectID = nil self.log.default("[connectOnDemand] no fresh discovery in 4s — direct (cold) connect") self.manager.stopScan() - self.manager.connect(peripheral, options: nil) + self.freshConnect(peripheral) } } } + /// Issue an on-demand connect with a stale-state flush. The first connect on a peripheral is + /// clean, but a cached CBPeripheral that was previously connected then cancelPeripheralConnection'd + /// wedges in .connecting on a bare reconnect (measured: every reconnect after an idle-disconnect + /// timed out at 20s while iOS reported it .disconnected + advertising connectable). Cancel any + /// lingering iOS-side connection intent and re-fetch the peripheral before connecting. + private func freshConnect(_ peripheral: CBPeripheral) { + dispatchPrecondition(condition: .onQueue(managerQueue)) + manager.cancelPeripheralConnection(peripheral) + let target = manager.retrievePeripherals(withIdentifiers: [peripheral.identifier]).first ?? peripheral + // Keep the session's peripheral reference in sync with the object we actually connect. + if let device = devices.first(where: { $0.manager.peripheral.identifier == peripheral.identifier }) { + device.manager.peripheral = target + } + manager.connect(target, options: nil) + } + private func startScanning() { let serviceUUID: CBUUID = podScanServiceUUID let services: [CBUUID]? @@ -757,7 +773,7 @@ extension BluetoothManager: CBCentralManagerDelegate { // connect -> it wedged in .connecting and timed out at 20s. Let iOS settle the // stopScan, then connect fully dark on the just-heard advert. managerQueue.async { [weak self] in - self?.manager.connect(peripheral, options: nil) + self?.freshConnect(peripheral) } } // Kick off / re-arm the delayed-connect probe once we know the pod is present + disconnected. From d81cd9b21a18d7e8a168618de4c4e53606933e89 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 3 Jul 2026 14:47:45 -0500 Subject: [PATCH 38/92] connect-on-demand: scan-free connect (measure plain connect latency) Strip all scanning: the pod is a known/recovered CBPeripheral (via retrievePeripherals), so connect-on-demand now issues a plain connect() and lets iOS reacquire it -- no fresh-discovery scan, no idle/alarm monitor scan. New scanningEnabled master flag (default off) no-ops startScanning() across every path. Still connect-on-demand only: connect when a session is scheduled, disconnect when idle. Measuring pure scan-free connect latency. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 13 +++++++++++++ OmnipodKit/Bluetooth/PeripheralManager.swift | 11 ++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 3ec48db..a90b4b9 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -200,6 +200,15 @@ class BluetoothManager: NSObject { UserDefaults.standard.object(forKey: "OmnipodKit.lowPowerMonitorEnabled") as? Bool ?? true } + /// Field-test master switch: when OFF, ALL scanning is disabled (idle/alarm/monitor/beacon AND + /// the connect-on-demand fresh-discovery helper). Connect-on-demand then issues a plain connect() + /// on the recovered peripheral and lets iOS reacquire it — no scan involved. Set to measure a + /// pure, scan-free connect latency. (The connectionless [ADV]/[POD-ALERT] detection is a scan + /// feature, so it's inert while this is off.) + static var scanningEnabled: Bool { + UserDefaults.standard.object(forKey: "OmnipodKit.scanningEnabled") as? Bool ?? false + } + /// Measurement mode (field-test only): skip ALL pod commands so the pod is left idle-disconnected /// and the wildcard scan runs uninterrupted — a clean window to measure the advert cadence and /// see whether a CE1F923D beacon ever appears, without connect churn stopping the scan. @@ -547,6 +556,10 @@ class BluetoothManager: NSObject { } private func startScanning() { + guard BluetoothManager.scanningEnabled else { + log.default("[connectOnDemand] scanning disabled — not starting a scan (scan-free connect mode)") + return + } let serviceUUID: CBUUID = podScanServiceUUID let services: [CBUUID]? let options: [String: Any] diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index 10fbe01..dd5f44f 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -371,13 +371,10 @@ extension PeripheralManager { do { try runCommand(timeout: timeout, allowDisconnected: true) { addCondition(.connect) - // Fast path: let BluetoothManager hear the pod first, then connect on the fresh advert - // (~1-2s) instead of a cold connect (~16s). Falls back to a direct connect if unavailable. - if let bt = bluetoothManager { - bt.connectViaFreshDiscovery(peripheral) - } else { - central?.connect(peripheral, options: nil) - } + // Scan-free connect: the peripheral is a known/recovered CBPeripheral (via + // retrievePeripherals), so a plain connect() registers a pending connect and iOS + // reacquires the pod on its own — no scan needed. Measuring this latency directly. + central?.connect(peripheral, options: nil) } } catch { // Unstick a connect that never completed, so didDisconnect/didFailToConnect fires From 69aee5333a662780946553699c4bed3bfb1630a2 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 3 Jul 2026 14:57:21 -0500 Subject: [PATCH 39/92] connect-on-demand: drive CBCentralManager only on managerQueue Root cause of the intermittent reconnect stall: connect-on-demand called central.connect() / cancelPeripheralConnection() from PeripheralManager's own queue, but the central was created with managerQueue. CoreBluetooth must be driven from its delegate queue so calls serialize against the callbacks and internal state machine; cross-queue calls raced the teardown and wedged the next connect in .connecting (peripheralState=1, 20s timeout). The idle-disconnect cancel was on the wrong queue in every failing run. Add BluetoothManager.connectOnDemand/disconnectOnDemand helpers that hop to managerQueue, and route PeripheralManager's connect + both cancels through them. Ensure bluetoothManager is wired on the existing-device branch of addPeripheral so the helpers are always reachable. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 27 ++++++++++++++++++++ OmnipodKit/Bluetooth/PeripheralManager.swift | 14 +++++----- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index a90b4b9..7e67238 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -376,6 +376,7 @@ class BluetoothManager: NSObject { if let device = device { log.default("Matched peripheral %{public}@ to existing device: %{public}@", peripheral, String(describing: device)) device.manager.peripheral = peripheral + device.manager.bluetoothManager = self // ensure the queue-correct central helpers are reachable if let podAdvertisement = podAdvertisement { device.advertisement = podAdvertisement } @@ -555,6 +556,32 @@ class BluetoothManager: NSObject { manager.connect(target, options: nil) } + // MARK: - Central calls (MUST run on managerQueue) + // + // CBCentralManager was created with `managerQueue`, so every call into it has to be serialized on + // that same queue — otherwise connect/cancel race the delegate callbacks and CoreBluetooth's + // internal state machine. Connect-on-demand was calling central.connect()/cancelPeripheralConnection() + // from PeripheralManager.queue, which wedged reconnects in .connecting (intermittently). These + // helpers give PeripheralManager a queue-correct way to drive the central. + + /// Connect the (known/recovered) peripheral on the central's queue. + func connectOnDemand(_ peripheral: CBPeripheral) { + managerQueue.async { [weak self] in + guard let self = self else { return } + self.log.default("[connectOnDemand] central.connect on managerQueue for %{public}@", peripheral.identifier.uuidString) + self.manager.connect(peripheral, options: nil) + } + } + + /// Cancel/disconnect the peripheral on the central's queue. + func disconnectOnDemand(_ peripheral: CBPeripheral) { + managerQueue.async { [weak self] in + guard let self = self else { return } + self.log.default("[connectOnDemand] central.cancel on managerQueue for %{public}@", peripheral.identifier.uuidString) + self.manager.cancelPeripheralConnection(peripheral) + } + } + private func startScanning() { guard BluetoothManager.scanningEnabled else { log.default("[connectOnDemand] scanning disabled — not starting a scan (scan-free connect mode)") diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index dd5f44f..74f4fd0 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -366,20 +366,20 @@ extension PeripheralManager { // TIMED OUT at 20s foreground, whereas the delayed-connect experiments (no scan) always // completed. So stop scanning and let iOS complete the connect; the alarm scan resumes on // disconnect. (Latency without a scan is iOS's own reacquisition; measure it, optimize next.) - central?.stopScan() let start = Date() do { try runCommand(timeout: timeout, allowDisconnected: true) { addCondition(.connect) // Scan-free connect: the peripheral is a known/recovered CBPeripheral (via // retrievePeripherals), so a plain connect() registers a pending connect and iOS - // reacquires the pod on its own — no scan needed. Measuring this latency directly. - central?.connect(peripheral, options: nil) + // reacquires the pod on its own — no scan needed. Route through BluetoothManager so + // the central call runs on managerQueue (see connectOnDemand helper). + bluetoothManager?.connectOnDemand(peripheral) } } catch { // Unstick a connect that never completed, so didDisconnect/didFailToConnect fires - // (which resumes the scan) instead of leaving it wedged in .connecting. - central?.cancelPeripheralConnection(peripheral) + // instead of leaving it wedged in .connecting. Queue-correct cancel via BluetoothManager. + bluetoothManager?.disconnectOnDemand(peripheral) throw error } log.default("[connectOnDemand] connected in %{public}@s", String(format: "%.3f", Date().timeIntervalSince(start))) @@ -712,7 +712,9 @@ extension PeripheralManager { guard self.idleStart == idleAt, self.sessionQueue.operationCount == 0, self.peripheral.state == .connected else { return } self.log.default("[connectOnDemand] idle ~%{public}ds, no queued session -> disconnecting", Int(idleDelay)) - self.central?.cancelPeripheralConnection(self.peripheral) + // Queue-correct cancel: route through BluetoothManager so it runs on managerQueue. Cancelling + // from this (PeripheralManager) queue raced CoreBluetooth's teardown and wedged the next connect. + self.bluetoothManager?.disconnectOnDemand(self.peripheral) } } } From b3476971000a24f74518a709faaaff8388318dbb Mon Sep 17 00:00:00 2001 From: Joe Moran Date: Thu, 2 Jul 2026 16:43:27 -0700 Subject: [PATCH 40/92] Properly handle and display times for DASH reset type pod faults --- .../OmnipodCommon/MessageBlocks/DetailedStatus.swift | 8 +++++--- OmnipodKit/PumpManagerUI/Views/PodDetailsView.swift | 12 ++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/OmnipodKit/OmnipodCommon/MessageBlocks/DetailedStatus.swift b/OmnipodKit/OmnipodCommon/MessageBlocks/DetailedStatus.swift index 1426557..88a43bf 100644 --- a/OmnipodKit/OmnipodCommon/MessageBlocks/DetailedStatus.swift +++ b/OmnipodKit/OmnipodCommon/MessageBlocks/DetailedStatus.swift @@ -59,9 +59,11 @@ struct DetailedStatus: PodInfo, Equatable { self.deliveryStatus = DeliveryStatus(rawValue: encodedData[2] & 0xf)! } - let minutesSinceActivation = encodedData[9...10].toBigEndian(UInt16.self) - if minutesSinceActivation != 0xffff { - self.faultEventTimeSinceActivation = TimeInterval(minutes: Double(minutesSinceActivation)) + let faultTime = encodedData[9...10].toBigEndian(UInt16.self) + /// For pod reset faults, this value can be $ffff (Eros or O5) and $0000 for (DASH). + /// Never return any of these dubious values as a possible valid pod fault time. + if faultTime != 0xffff && faultTime != 0 { + self.faultEventTimeSinceActivation = TimeInterval(minutes: Double(faultTime)) } else { self.faultEventTimeSinceActivation = nil } diff --git a/OmnipodKit/PumpManagerUI/Views/PodDetailsView.swift b/OmnipodKit/PumpManagerUI/Views/PodDetailsView.swift index bf9a8fa..cd92421 100644 --- a/OmnipodKit/PumpManagerUI/Views/PodDetailsView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PodDetailsView.swift @@ -78,17 +78,21 @@ struct PodDetailsView: View { } } - /// Returns the string for the number of hours pod was active + /// Returns the string for the number of hours pod was active to two decimals. This value is always + /// based on the pod's internal clock whether it is from a faulted, deactivated or currently active pod. var podHoursText: String { let podTime: TimeInterval - // If pod faulted, use the faultTime instead of podTime - if let fault = podDetails.fault, let faultTime = fault.faultEventTimeSinceActivation { + if let faultTime = podDetails.fault?.faultEventTimeSinceActivation { + /// Since the pod has a valid faultEventTimeSinceActivation, use this rather than the pod's time. + /// Reset type pod faults return atypical fault time values that will not be considered valid on decode. podTime = faultTime } else { + /// Otherwise use the always increasing podTime to compute the pod + /// hours for reset pod faults, deactivated pods, or currently active pods. podTime = podDetails.podTime } - return String(format: "%.02f", podTime.hours) + return podTime.hours.twoDecimals } var lastStatusText: String { From 2f8fdea158bedb7976b5515f2be34140cf64766c Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 6 Jul 2026 17:07:02 -0500 Subject: [PATCH 41/92] heartbeat: serialize delayed-connect probe with commands; fire the heartbeat The pump-provided heartbeat (delayed-connect StartDelay probe, used when there is no BLE CGM) was thrashing: any didConnect during the ~300s probe window was treated as the probe (delayedProbeInFlight), so real command connects got hijacked and cancelled after 2s -> 471 disconnects, stale pump data. StartDelay itself works (a clean 302.4s connect proves it). Serialize the two so they are never outstanding together: - A command connect preempts the probe: cancel the in-flight StartDelay probe, set commandConnectInFlight, then connect. A command connect is therefore never mis-claimed as a probe. - Arm/re-arm the probe only when idle (!commandConnectInFlight); clear the flag on the idle-disconnect so the probe re-arms. - On a genuine probe wake, drop the wake link and fire the heartbeat from the resulting didDisconnect (new omnipodHeartbeatDidFire delegate hop -> OmniPumpManager.issueHeartbeatIfNeeded -> pumpManagerBLEHeartbeatDidFire), so Loop runs a cycle and its commands connect on demand from a clean disconnected state. Previously the probe never fired the heartbeat. Net: idle -> ~5min StartDelay wake -> heartbeat -> connect-on-demand cycle -> idle, with commands preempting the probe cleanly in between. --- OmnipodKit/Bluetooth/BlePodComms.swift | 4 ++ OmnipodKit/Bluetooth/BluetoothManager.swift | 70 ++++++++++++++++---- OmnipodKit/PumpManager/OmniPumpManager.swift | 6 ++ 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index d65ea30..8d88037 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -768,6 +768,10 @@ extension BlePodComms: OmniConnectionDelegate { delegate?.omnipodLogDeviceEvent(message) } + func omnipodHeartbeatDidFire() { + delegate?.omnipodHeartbeatDidFire() + } + func omnipodPeripheralWasRestored(manager: PeripheralManager) { if let podState = podState, manager.peripheral.identifier.uuidString == podState.bleIdentifier { log.bleDebug("omnipodPeripheralWasRestored for %@", manager.peripheral.identifier.uuidString) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 7e67238..9ea391e 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -91,10 +91,15 @@ protocol OmniConnectionDelegate: AnyObject { /// Write a message to Loop's persistent device log (survives background wakes / relaunch and is /// bundled in the issue report, unlike a live Console stream). func omnipodLogDeviceEvent(_ message: String) + + /// Tells the delegate a pump-provided heartbeat wake fired (a delayed-connect probe completed). + /// The host (OmniPumpManager) turns this into pumpManagerBLEHeartbeatDidFire so Loop runs a cycle. + func omnipodHeartbeatDidFire() } extension OmniConnectionDelegate { func omnipodLogDeviceEvent(_ message: String) {} + func omnipodHeartbeatDidFire() {} } @@ -252,6 +257,17 @@ class BluetoothManager: NSObject { private var delayedProbeInFlight = false private var delayedProbeIssuedAt: Date? + /// True while a real command's connect owns the link (connect-on-demand). The heartbeat probe and + /// a command connect must never be outstanding together — a command preempts the probe and, while + /// it's active, the probe is neither armed nor allowed to claim a didConnect. Cleared on the + /// idle-disconnect (going idle) so the probe re-arms. managerQueue-isolated. + private var commandConnectInFlight = false + + /// Set when a delayed-connect probe completes (a heartbeat wake): we disconnect the wake link and + /// fire the heartbeat from the resulting didDisconnect, so Loop's commands start from a clean + /// disconnected state via connect-on-demand. managerQueue-isolated. + private var pendingHeartbeatFire = false + /// True once this PROCESS has ever been foregrounded. A [delayedConnect] with everFg=false means /// iOS ran this process entirely in the background — proof of a background wake/relaunch the user /// did NOT initiate (a manual open would have foregrounded it). Set on the main queue via a @@ -308,7 +324,7 @@ class BluetoothManager: NSObject { /// Issue a connect with CBConnectPeripheralOptionStartDelayKey and record the time, for the /// timed-wake experiment. iOS holds the request for `delayedConnectProbeSeconds`, then connects. private func issueDelayedConnectProbe(_ peripheral: CBPeripheral) { - guard delayedConnectProbeActive, !delayedProbeInFlight, + guard delayedConnectProbeActive, !delayedProbeInFlight, !commandConnectInFlight, peripheral.state == .disconnected else { return } let delay = BluetoothManager.delayedConnectProbeSeconds // Stop the allowDuplicates scan so it doesn't starve the post-delay connect (iOS reacquires the @@ -564,19 +580,32 @@ class BluetoothManager: NSObject { // from PeripheralManager.queue, which wedged reconnects in .connecting (intermittently). These // helpers give PeripheralManager a queue-correct way to drive the central. - /// Connect the (known/recovered) peripheral on the central's queue. + /// Connect the (known/recovered) peripheral on the central's queue for a real command. A command + /// preempts the heartbeat probe: cancel any in-flight StartDelay probe first so its pending connect + /// can't complete and get mis-attributed as this command connect, then mark the command in flight + /// (so the probe won't re-arm or claim the didConnect) and connect. func connectOnDemand(_ peripheral: CBPeripheral) { managerQueue.async { [weak self] in guard let self = self else { return } + if self.delayedProbeInFlight { + self.log.default("[connectOnDemand] command preempts heartbeat probe — cancelling probe") + self.delayedProbeInFlight = false + self.delayedProbeIssuedAt = nil + self.manager.cancelPeripheralConnection(peripheral) + } + self.commandConnectInFlight = true self.log.default("[connectOnDemand] central.connect on managerQueue for %{public}@", peripheral.identifier.uuidString) self.manager.connect(peripheral, options: nil) } } - /// Cancel/disconnect the peripheral on the central's queue. + /// Cancel/disconnect the peripheral on the central's queue. This is the idle-disconnect / teardown + /// path, so we're going idle: clear commandConnectInFlight so the resulting didDisconnect re-arms + /// the heartbeat probe. func disconnectOnDemand(_ peripheral: CBPeripheral) { managerQueue.async { [weak self] in guard let self = self else { return } + self.commandConnectInFlight = false self.log.default("[connectOnDemand] central.cancel on managerQueue for %{public}@", peripheral.identifier.uuidString) self.manager.cancelPeripheralConnection(peripheral) } @@ -859,18 +888,24 @@ extension BluetoothManager: CBCentralManagerDelegate { // (incl. a restored one after relaunch) is a probe and must re-arm. // In normal (command) mode we must NOT hijack a real command connect, so only genuine // in-flight probes qualify. - let treatAsProbe = delayedProbeInFlight || (delayedConnectProbeActive && BluetoothManager.suppressCommandsEnabled) + // A genuine heartbeat-probe wake: the StartDelay connect WE issued completed, and no command + // is using the link. (A command connect sets commandConnectInFlight and clears delayedProbeInFlight, + // so it never lands here — that was the old hijack that cancelled real commands after 2s.) + let treatAsProbe = (delayedProbeInFlight && !commandConnectInFlight) + || (delayedConnectProbeActive && BluetoothManager.suppressCommandsEnabled) if treatAsProbe { let measured = delayedProbeIssuedAt.map { String(format: "%.1f", Date().timeIntervalSince($0)) } ?? "?(restored)" let pid = ProcessInfo.processInfo.processIdentifier - log.default("[delayedConnect] pid=%{public}d everFg=%{public}@ CONNECTED after %{public}@s (StartDelay=%{public}ds) %{public}@", + log.default("[delayedConnect] pid=%{public}d everFg=%{public}@ CONNECTED after %{public}@s (StartDelay=%{public}ds) %{public}@ — heartbeat wake", pid, String(everForeground), measured, BluetoothManager.delayedConnectProbeSeconds, peripheral.identifier.uuidString) - connectionDelegate?.omnipodLogDeviceEvent("[delayedConnect] pid=\(pid) everFg=\(everForeground) CONNECTED after \(measured)s (StartDelay=\(BluetoothManager.delayedConnectProbeSeconds)s)") + connectionDelegate?.omnipodLogDeviceEvent("[delayedConnect] pid=\(pid) everFg=\(everForeground) CONNECTED after \(measured)s (StartDelay=\(BluetoothManager.delayedConnectProbeSeconds)s) — heartbeat wake") delayedProbeInFlight = false delayedProbeIssuedAt = nil - managerQueue.asyncAfter(deadline: .now() + 2.0) { [weak self] in - self?.manager.cancelPeripheralConnection(peripheral) - } + // Drop the wake connection and fire the heartbeat from didDisconnect (clean idle state), so + // Loop's resulting status/dose commands run via connect-on-demand rather than fighting this + // transient probe link. + pendingHeartbeatFire = true + manager.cancelPeripheralConnection(peripheral) return } @@ -912,14 +947,21 @@ extension BluetoothManager: CBCentralManagerDelegate { autoReconnect(peripheral) } delayedProbeInFlight = false - if delayedConnectProbeActive { - // Re-arm the delayed connect RIGHT HERE (not via a later didDiscover). This leaves a - // pending connect that survives app suspension, so the loop self-sustains — relying on - // the scan to re-issue stalled whenever iOS suspended the app between cycles. + // Re-arm the heartbeat probe only when idle — never while a command owns the link (that + // overlap was the source of the connect/disconnect thrash). The re-arm leaves a pending + // StartDelay connect that survives app suspension, so the loop self-sustains. + if delayedConnectProbeActive && !commandConnectInFlight { issueDelayedConnectProbe(peripheral) } else { resumeScanIfNeeded() } + // If this disconnect ended a heartbeat-probe wake, fire the heartbeat now (clean idle state) so + // Loop runs its cycle; its commands then preempt the just-armed probe via connect-on-demand. + if pendingHeartbeatFire { + pendingHeartbeatFire = false + log.default("[delayedConnect] firing heartbeat (pumpManagerBLEHeartbeatDidFire)") + connectionDelegate?.omnipodHeartbeatDidFire() + } } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { @@ -933,7 +975,7 @@ extension BluetoothManager: CBCentralManagerDelegate { autoReconnect(peripheral) } delayedProbeInFlight = false - if delayedConnectProbeActive { + if delayedConnectProbeActive && !commandConnectInFlight { issueDelayedConnectProbe(peripheral) // re-arm so a failed connect doesn't stall the loop } else { resumeScanIfNeeded() diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index e1648a7..5c59187 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -375,6 +375,12 @@ public class OmniPumpManager: RileyLinkPumpManager { logDeviceCommunication(message, type: .connection) } + func omnipodHeartbeatDidFire() { + // A pump-provided heartbeat wake (delayed-connect probe) completed — run a Loop cycle. Loop's + // resulting status/dose commands connect on demand (which preempts the re-armed probe). + issueHeartbeatIfNeeded() + } + func omnipodPeripheralDidConnect(manager: PeripheralManager) { logDeviceCommunication("Pod connected \(manager.peripheral.identifier.uuidString)", type: .connection) notifyPodConnectionStateDidChange(isConnected: true) From a9e4682b636e6bef1d8c471945061376fc737b44 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 6 Jul 2026 19:45:52 -0500 Subject: [PATCH 42/92] heartbeat: log probe-fire/command-preempt/pumpManagerBLEHeartbeatDidFire to device log Route the heartbeat serialization events to the persistent device log (omnipodLogDeviceEvent) so they appear in the Issue Report, not just os_log: [connectOnDemand] command preempts heartbeat probe, [delayedConnect] firing heartbeat, and (at the Loop-facing end) the actual pumpManagerBLEHeartbeatDidFire call in issueHeartbeatIfNeeded. Makes the full wake -> fire -> Loop-cycle chain visible for verification. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 2 ++ OmnipodKit/PumpManager/OmniPumpManager.swift | 1 + 2 files changed, 3 insertions(+) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 9ea391e..4e81bde 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -589,6 +589,7 @@ class BluetoothManager: NSObject { guard let self = self else { return } if self.delayedProbeInFlight { self.log.default("[connectOnDemand] command preempts heartbeat probe — cancelling probe") + self.connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] command preempts heartbeat probe — cancelling probe") self.delayedProbeInFlight = false self.delayedProbeIssuedAt = nil self.manager.cancelPeripheralConnection(peripheral) @@ -960,6 +961,7 @@ extension BluetoothManager: CBCentralManagerDelegate { if pendingHeartbeatFire { pendingHeartbeatFire = false log.default("[delayedConnect] firing heartbeat (pumpManagerBLEHeartbeatDidFire)") + connectionDelegate?.omnipodLogDeviceEvent("[delayedConnect] firing heartbeat (pumpManagerBLEHeartbeatDidFire)") connectionDelegate?.omnipodHeartbeatDidFire() } } diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index 5c59187..2c1aafc 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -364,6 +364,7 @@ public class OmniPumpManager: RileyLinkPumpManager { private func issueHeartbeatIfNeeded() { if self.provideHeartbeat, dateGenerator().timeIntervalSince(lastHeartbeat) > .minutes(2) { + logDeviceCommunication("[heartbeat] pumpManagerBLEHeartbeatDidFire — Loop cycle triggered", type: .connection) self.pumpDelegate.notify { (delegate) in delegate?.pumpManagerBLEHeartbeatDidFire(self) } From f8b4b624d5e1f1fc87c75c43365ed3fb673e60f3 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 6 Jul 2026 20:33:10 -0500 Subject: [PATCH 43/92] connect-on-demand: fresh-discovery for command connects (fast user commands) Command connects were bare scan-free connect()s that waited out iOS's duty-cycled reacquisition -- a user-initiated Suspend took ~15s (cold), ~5s (warm). Route command connects back through fresh-discovery: briefly scan for the pod's advert and connect on it (~1-2s), 4s cold-connect fallback. The In-Play flakiness that made us strip this out was pod hardware; the TWI pod connects reliably. The heartbeat probe still uses StartDelay and stays serialized with commands via commandConnectInFlight. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 4e81bde..f9687a6 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -595,8 +595,13 @@ class BluetoothManager: NSObject { self.manager.cancelPeripheralConnection(peripheral) } self.commandConnectInFlight = true - self.log.default("[connectOnDemand] central.connect on managerQueue for %{public}@", peripheral.identifier.uuidString) - self.manager.connect(peripheral, options: nil) + // Fresh-discovery connect: briefly scan for the pod and connect on its just-heard advert + // (~1-2s) instead of a bare cold connect() that waits out iOS's duty-cycled reacquisition + // (~10-16s — the slow user-initiated Suspend). Falls back to a cold connect after 4s if the + // pod isn't heard. (The heartbeat probe still uses StartDelay; the two stay serialized via + // commandConnectInFlight.) + self.log.default("[connectOnDemand] fresh-discovery command connect for %{public}@", peripheral.identifier.uuidString) + self.connectViaFreshDiscovery(peripheral) } } From ee9f3825671ed86ee3799233a0b2616292cf20cb Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 6 Jul 2026 21:45:48 -0500 Subject: [PATCH 44/92] connect-on-demand: direct connect on fresh advert + device-log the fresh-discovery steps Command connects dropped from ~15s to ~4-6s with fresh-discovery but not to the ~1-2s target. Two changes to close the gap and see where the time goes: - Connect directly to the just-heard peripheral in didDiscover instead of freshConnect (which cancels + re-retrieves first) -- that stale-flush was an In-Play stall workaround and adds a round-trip the TWI pod doesn't need. The cold-connect fallback keeps the flush. - Route the fresh-discovery milestones (scan started, pod heard -> connect, 4s cold fallback) to the device log so the Issue Report shows the scan-to-advert vs advert-to-connect breakdown. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index f9687a6..2d1f5c6 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -546,10 +546,12 @@ class BluetoothManager: NSObject { self.manager.stopScan() self.manager.scanForPeripherals(withServices: [self.podScanServiceUUID], options: nil) self.log.default("[connectOnDemand] fresh-discovery scan for %{public}@", id) + self.connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] fresh-discovery scan started") self.managerQueue.asyncAfter(deadline: .now() + 4.0) { [weak self] in guard let self = self, self.pendingFreshConnectID == id else { return } self.pendingFreshConnectID = nil self.log.default("[connectOnDemand] no fresh discovery in 4s — direct (cold) connect") + self.connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] no fresh discovery in 4s — cold connect fallback") self.manager.stopScan() self.freshConnect(peripheral) } @@ -842,13 +844,16 @@ extension BluetoothManager: CBCentralManagerDelegate { if pendingFreshConnectID == peripheral.identifier.uuidString { pendingFreshConnectID = nil log.default("[connectOnDemand] fresh discovery -> connect %{public}@", peripheral.identifier.uuidString) + connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] pod heard -> connecting on fresh advert") manager.stopScan() // Defer the connect one managerQueue tick so the scan actually tears down first. // Connecting synchronously here (still inside the scan's didDiscover) starved the // connect -> it wedged in .connecting and timed out at 20s. Let iOS settle the - // stopScan, then connect fully dark on the just-heard advert. + // stopScan, then connect on the just-heard advert. Direct connect (not freshConnect): + // the peripheral was just heard and is connectable, so skip the cancel+re-retrieve + // stale-flush (an In-Play stall workaround) that added a round-trip on the good pod. managerQueue.async { [weak self] in - self?.freshConnect(peripheral) + self?.manager.connect(peripheral, options: nil) } } // Kick off / re-arm the delayed-connect probe once we know the pod is present + disconnected. From d8f43b724b47b0c1d645b24e29d7f0fb53217f04 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 6 Jul 2026 22:00:22 -0500 Subject: [PATCH 45/92] connect-on-demand: foreground keep-alive (live connection-gated UI, instant in-app commands) The beep button (and other UI) is disabled while the pod is disconnected, so in the normally-disconnected model it was ghosted whenever idle. Keep the pod connected while the app is foregrounded: - On foreground: pre-connect (fresh-discovery) so connection-gated UI is live and commands run on the existing session (no per-command connect + handshake). - While foreground: skip the idle-disconnect; reconnect on an unintended drop (a deliberate background/idle disconnect clears commandConnectInFlight first, so it isn't treated as a drop). - On background: disconnect and resume the ~5-min heartbeat probe. Refactor the command-connect body into beginCommandConnect (managerQueue) so foreground pre-connect and connect-on-demand share it. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 110 ++++++++++++++----- OmnipodKit/Bluetooth/PeripheralManager.swift | 7 ++ 2 files changed, 92 insertions(+), 25 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 2d1f5c6..8c0fa4c 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -268,6 +268,13 @@ class BluetoothManager: NSObject { /// disconnected state via connect-on-demand. managerQueue-isolated. private var pendingHeartbeatFire = false + /// True while the app is foregrounded. While foreground we keep the pod connected (skip the + /// idle-disconnect, reconnect on an unintended drop) so connection-gated UI (test beeps, etc.) is + /// live and in-app commands are instant. On background we disconnect and resume the heartbeat probe. + private var isAppForeground = false + /// Cross-queue read for PeripheralManager's idle-disconnect (benign bool race, like everForeground). + var appIsForeground: Bool { isAppForeground } + /// True once this PROCESS has ever been foregrounded. A [delayedConnect] with everFg=false means /// iOS ran this process entirely in the background — proof of a background wake/relaunch the user /// did NOT initiate (a manual open would have foregrounded it). Set on the main queue via a @@ -367,15 +374,23 @@ class BluetoothManager: NSObject { // persistent device log with PID for the timeline. let center = NotificationCenter.default center.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in - self?.everForeground = true let pid = ProcessInfo.processInfo.processIdentifier - self?.log.default("[lifecycle] pid=%{public}d APP FOREGROUND", pid) - self?.connectionDelegate?.omnipodLogDeviceEvent("[lifecycle] pid=\(pid) APP FOREGROUND") + self?.managerQueue.async { + guard let self = self else { return } + self.everForeground = true + self.log.default("[lifecycle] pid=%{public}d APP FOREGROUND", pid) + self.connectionDelegate?.omnipodLogDeviceEvent("[lifecycle] pid=\(pid) APP FOREGROUND") + self.enterForeground() + } } center.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in let pid = ProcessInfo.processInfo.processIdentifier - self?.log.default("[lifecycle] pid=%{public}d APP BACKGROUND", pid) - self?.connectionDelegate?.omnipodLogDeviceEvent("[lifecycle] pid=\(pid) APP BACKGROUND") + self?.managerQueue.async { + guard let self = self else { return } + self.log.default("[lifecycle] pid=%{public}d APP BACKGROUND", pid) + self.connectionDelegate?.omnipodLogDeviceEvent("[lifecycle] pid=\(pid) APP BACKGROUND") + self.enterBackground() + } } } @@ -588,22 +603,61 @@ class BluetoothManager: NSObject { /// (so the probe won't re-arm or claim the didConnect) and connect. func connectOnDemand(_ peripheral: CBPeripheral) { managerQueue.async { [weak self] in - guard let self = self else { return } - if self.delayedProbeInFlight { - self.log.default("[connectOnDemand] command preempts heartbeat probe — cancelling probe") - self.connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] command preempts heartbeat probe — cancelling probe") - self.delayedProbeInFlight = false - self.delayedProbeIssuedAt = nil - self.manager.cancelPeripheralConnection(peripheral) - } - self.commandConnectInFlight = true - // Fresh-discovery connect: briefly scan for the pod and connect on its just-heard advert - // (~1-2s) instead of a bare cold connect() that waits out iOS's duty-cycled reacquisition - // (~10-16s — the slow user-initiated Suspend). Falls back to a cold connect after 4s if the - // pod isn't heard. (The heartbeat probe still uses StartDelay; the two stay serialized via - // commandConnectInFlight.) - self.log.default("[connectOnDemand] fresh-discovery command connect for %{public}@", peripheral.identifier.uuidString) - self.connectViaFreshDiscovery(peripheral) + self?.beginCommandConnect(peripheral) + } + } + + /// Start a command (or keep-alive) connect. Must run on managerQueue. + private func beginCommandConnect(_ peripheral: CBPeripheral) { + dispatchPrecondition(condition: .onQueue(managerQueue)) + if delayedProbeInFlight { + log.default("[connectOnDemand] command preempts heartbeat probe — cancelling probe") + connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] command preempts heartbeat probe — cancelling probe") + delayedProbeInFlight = false + delayedProbeIssuedAt = nil + manager.cancelPeripheralConnection(peripheral) + } + commandConnectInFlight = true + // Fresh-discovery connect: briefly scan for the pod and connect on its just-heard advert + // (~1-2s) instead of a bare cold connect() that waits out iOS's duty-cycled reacquisition + // (~10-16s — the slow user-initiated Suspend). Falls back to a cold connect after 4s if the + // pod isn't heard. (The heartbeat probe still uses StartDelay; the two stay serialized via + // commandConnectInFlight.) + log.default("[connectOnDemand] fresh-discovery command connect for %{public}@", peripheral.identifier.uuidString) + connectViaFreshDiscovery(peripheral) + } + + /// The known/autoconnect pod peripheral, for foreground keep-alive and heartbeat. + private var keepAlivePeripheral: CBPeripheral? { + let device = devices.first(where: { autoConnectIDs.contains($0.manager.peripheral.identifier.uuidString) }) ?? devices.first + return device?.manager.peripheral + } + + /// App entered the foreground: keep the pod connected so connection-gated UI is live and commands + /// are instant. Pre-connect if it's currently disconnected. (Idle-disconnect is skipped while + /// foreground; a drop is reconnected in didDisconnect.) + private func enterForeground() { + dispatchPrecondition(condition: .onQueue(managerQueue)) + isAppForeground = true + if let peripheral = keepAlivePeripheral, peripheral.state == .disconnected { + log.default("[connectOnDemand] foreground — pre-connecting for keep-alive") + connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] foreground — pre-connecting for keep-alive") + beginCommandConnect(peripheral) + } + } + + /// App entered the background: drop the kept-alive connection and resume the ~5-min heartbeat probe. + private func enterBackground() { + dispatchPrecondition(condition: .onQueue(managerQueue)) + isAppForeground = false + guard let peripheral = keepAlivePeripheral else { return } + commandConnectInFlight = false // deliberate disconnect: let the probe re-arm + if peripheral.state == .connected || peripheral.state == .connecting { + log.default("[connectOnDemand] background — disconnecting, resuming heartbeat probe") + connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] background — disconnecting, resuming heartbeat probe") + manager.cancelPeripheralConnection(peripheral) // didDisconnect arms the probe + } else { + issueDelayedConnectProbe(peripheral) // already disconnected — arm directly } } @@ -958,10 +1012,16 @@ extension BluetoothManager: CBCentralManagerDelegate { autoReconnect(peripheral) } delayedProbeInFlight = false - // Re-arm the heartbeat probe only when idle — never while a command owns the link (that - // overlap was the source of the connect/disconnect thrash). The re-arm leaves a pending - // StartDelay connect that survives app suspension, so the loop self-sustains. - if delayedConnectProbeActive && !commandConnectInFlight { + if isAppForeground && commandConnectInFlight { + // Foreground keep-alive: an unintended drop while we want to stay connected (a deliberate + // background/idle disconnect clears commandConnectInFlight first, so it won't reconnect). + log.default("[connectOnDemand] foreground keep-alive — reconnecting after drop") + connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] foreground keep-alive — reconnecting after drop") + connectViaFreshDiscovery(peripheral) + } else if delayedConnectProbeActive && !commandConnectInFlight { + // Re-arm the heartbeat probe only when idle — never while a command owns the link (that + // overlap was the source of the connect/disconnect thrash). The re-arm leaves a pending + // StartDelay connect that survives app suspension, so the loop self-sustains. issueDelayedConnectProbe(peripheral) } else { resumeScanIfNeeded() diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index 74f4fd0..e1b9cf1 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -708,6 +708,13 @@ extension PeripheralManager { let idleAt = idleStart queue.asyncAfter(deadline: .now() + idleDelay) { [weak self] in guard let self = self, BluetoothManager.connectOnDemandEnabled else { return } + // Foreground keep-alive: while the app is active, stay connected so connection-gated UI + // (test beeps, etc.) is live and in-app commands are instant. The background transition + // disconnects and resumes the heartbeat probe. + if self.bluetoothManager?.appIsForeground == true { + self.log.default("[connectOnDemand] app foreground — keeping pod connected (skip idle-disconnect)") + return + } // Only disconnect if we're still idle (no newer session) and nothing is queued/running. guard self.idleStart == idleAt, self.sessionQueue.operationCount == 0, self.peripheral.state == .connected else { return } From 5d3c9c90e75f52b66a5c4841d134494a28f3b330 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 6 Jul 2026 23:30:53 -0500 Subject: [PATCH 46/92] fault-listener: enable alarm-filtered idle scan + device-log POD-ALERT/POD-STATUS Turn the idle scan back on (scanningEnabled default true) so the unsolicited fault listener runs: while disconnected, an alarm-filtered scan (C005) wakes us only when the pod enters an alert state, and detectPodAlertStatus reads the alert from the advertisement with no connection. Route POD-ALERT/POD-STATUS to the device log so it shows in the Issue Report. Command connects still use fresh-discovery (unaffected). Caveat: the heartbeat StartDelay probe stops this scan, so fault-listening + pump heartbeat don't yet coexist -- validate the listener with a BLE CGM present (heartbeat off). --- OmnipodKit/Bluetooth/BluetoothManager.swift | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 8c0fa4c..548e0f0 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -205,13 +205,16 @@ class BluetoothManager: NSObject { UserDefaults.standard.object(forKey: "OmnipodKit.lowPowerMonitorEnabled") as? Bool ?? true } - /// Field-test master switch: when OFF, ALL scanning is disabled (idle/alarm/monitor/beacon AND - /// the connect-on-demand fresh-discovery helper). Connect-on-demand then issues a plain connect() - /// on the recovered peripheral and lets iOS reacquire it — no scan involved. Set to measure a - /// pure, scan-free connect latency. (The connectionless [ADV]/[POD-ALERT] detection is a scan - /// feature, so it's inert while this is off.) + /// Field-test master switch for the IDLE scan (startScanning). ON = run the alarm-filtered + /// low-power scan while disconnected — the unsolicited fault listener: iOS wakes us only on an + /// alert advertisement (`C005`), so `detectPodAlertStatus` catches faults connectionlessly (zero + /// wakes in normal operation). OFF = no idle scan (pure scan-free connect measurement). + /// NOTE: command connects use fresh-discovery (a direct scanForPeripherals) either way; this only + /// governs the idle listener. NOTE: while a heartbeat StartDelay probe is active it stops this scan + /// to avoid starving the probe connect — so fault-listening + heartbeat don't yet fully coexist + /// (test the listener with a BLE CGM present, i.e. heartbeat off). static var scanningEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.scanningEnabled") as? Bool ?? false + UserDefaults.standard.object(forKey: "OmnipodKit.scanningEnabled") as? Bool ?? true } /// Measurement mode (field-test only): skip ALL pod commands so the pod is left idle-disconnected @@ -853,9 +856,11 @@ extension BluetoothManager: CBCentralManagerDelegate { lastPodStatusWord[id] = status log.default("[POD-STATUS] %{public}@ status=%{public}@ (%{public}@) — connectionless detect", id, status.hexadecimalString, isAlert ? "non-clear/ALERT" : "clear") + connectionDelegate?.omnipodLogDeviceEvent("[POD-STATUS] status=\(status.hexadecimalString) (\(isAlert ? "non-clear/ALERT" : "clear")) — connectionless detect") if wasAlert != isAlert { log.default("[POD-ALERT] %{public}@ → %{public}@ (from advertisement, no connect)", id, isAlert ? "ALERT ACTIVE" : "CLEARED") + connectionDelegate?.omnipodLogDeviceEvent("[POD-ALERT] → \(isAlert ? "ALERT ACTIVE" : "CLEARED") (from advertisement, no connect)") } } From 95868b09719258ccfca6b438fd1326026ec0319c Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 7 Jul 2026 08:32:08 -0500 Subject: [PATCH 47/92] fault-listener: coexist the alarm scan with the heartbeat probe The StartDelay heartbeat probe was stopping ALL scanning, so while it was armed (~5 min at a time) the fault listener was down. The 'scan starves connect' lesson was about the heavy allowDuplicates wildcard scan; the alarm-filtered scan (C005, non-allowDuplicates) is light. So: - issueDelayedConnectProbe no longer stops the alarm scan (only a heavy allowDuplicates scan). - didDisconnect/didFailToConnect/enterBackground now keep the alarm scan running AND arm the probe alongside it, so faults are caught during the heartbeat wait. didConnect still stops the scan for the connection's duration; it resumes on disconnect. Test: with a heartbeat active (no BLE CGM), confirm both the ~5-min heartbeat fires AND a triggered alert is detected connectionlessly. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 27 ++++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 548e0f0..4abc089 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -337,9 +337,11 @@ class BluetoothManager: NSObject { guard delayedConnectProbeActive, !delayedProbeInFlight, !commandConnectInFlight, peripheral.state == .disconnected else { return } let delay = BluetoothManager.delayedConnectProbeSeconds - // Stop the allowDuplicates scan so it doesn't starve the post-delay connect (iOS reacquires the - // pod on its own — it advertises ~1Hz). The scan resumes on the probe's disconnect. - if manager.isScanning { manager.stopScan() } + // Fault-listener coexistence: keep the alarm-filtered scan (C005, non-allowDuplicates — light) + // running alongside the StartDelay probe, so faults are still caught during the ~5-min heartbeat + // wait. Only a HEAVY allowDuplicates scan (monitor/beacon mode) starves the connect, so stop + // just that. didConnect stops whatever scan remains for the duration of the connection. + if manager.isScanning, !BluetoothManager.lowPowerMonitorEnabled { manager.stopScan() } delayedProbeInFlight = true delayedProbeIssuedAt = Date() let pid = ProcessInfo.processInfo.processIdentifier @@ -658,9 +660,10 @@ class BluetoothManager: NSObject { if peripheral.state == .connected || peripheral.state == .connecting { log.default("[connectOnDemand] background — disconnecting, resuming heartbeat probe") connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] background — disconnecting, resuming heartbeat probe") - manager.cancelPeripheralConnection(peripheral) // didDisconnect arms the probe + manager.cancelPeripheralConnection(peripheral) // didDisconnect resumes scan + arms probe } else { - issueDelayedConnectProbe(peripheral) // already disconnected — arm directly + resumeScanIfNeeded() // fault-listener scan while idle + issueDelayedConnectProbe(peripheral) // + heartbeat probe alongside (if needed) } } @@ -1023,13 +1026,14 @@ extension BluetoothManager: CBCentralManagerDelegate { log.default("[connectOnDemand] foreground keep-alive — reconnecting after drop") connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] foreground keep-alive — reconnecting after drop") connectViaFreshDiscovery(peripheral) - } else if delayedConnectProbeActive && !commandConnectInFlight { - // Re-arm the heartbeat probe only when idle — never while a command owns the link (that - // overlap was the source of the connect/disconnect thrash). The re-arm leaves a pending - // StartDelay connect that survives app suspension, so the loop self-sustains. - issueDelayedConnectProbe(peripheral) } else { + // Idle: run the fault-listener alarm scan, AND (if a heartbeat is needed) arm the StartDelay + // probe alongside it. The two coexist — the scan is light and issueDelayedConnectProbe no + // longer stops it. Re-arm the probe only when idle (never while a command owns the link). resumeScanIfNeeded() + if delayedConnectProbeActive && !commandConnectInFlight { + issueDelayedConnectProbe(peripheral) + } } // If this disconnect ended a heartbeat-probe wake, fire the heartbeat now (clean idle state) so // Loop runs its cycle; its commands then preempt the just-armed probe via connect-on-demand. @@ -1052,10 +1056,9 @@ extension BluetoothManager: CBCentralManagerDelegate { autoReconnect(peripheral) } delayedProbeInFlight = false + resumeScanIfNeeded() // keep the fault-listener alarm scan running while idle if delayedConnectProbeActive && !commandConnectInFlight { issueDelayedConnectProbe(peripheral) // re-arm so a failed connect doesn't stall the loop - } else { - resumeScanIfNeeded() } } } From 5cd74e70b4bd3664609cc5996d72003571cd72db Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 7 Jul 2026 11:24:19 -0500 Subject: [PATCH 48/92] fault-listener: decode advert alert byte into AlertSet slot names The status word's last byte (b3) is the AlertSet bitmask (bit N = slot N); decode it with AlertSet(rawValue:) so [POD-ALERT]/[POD-STATUS] log the actual slot (e.g. slot3ExpirationReminder) instead of raw hex. Also log b1 (carries a baseline 0x02 slot1 'NotUsed' + the alert bit) as a cross-check while enumerating alert types. Foundation for wiring detection -> Loop. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 22 ++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 4abc089..0e687d0 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -854,16 +854,24 @@ extension BluetoothManager: CBCentralManagerDelegate { guard let status = podStatusWord(from: advertisementData) else { return } let id = peripheral.identifier.uuidString guard lastPodStatusWord[id] != status else { return } // only on change + // The 4-byte status word is [b0 b1 b2 b3]. b3 is the AlertSet bitmask (bit N = slot N firing); + // e.g. clear=…00, expiration-reminder(slot3)=…08. b1 carries a baseline 0x02 (slot1 "NotUsed") + // plus the same alert bit, so we log it too as a cross-check while enumerating alert types. + let bytes = Array(status) + let alertByte: UInt8 = bytes.count >= 4 ? bytes[3] : 0 + let statusByte1: UInt8 = bytes.count >= 2 ? bytes[1] : 0 + let alertSet = AlertSet(rawValue: alertByte) let wasAlert = lastPodStatusWord[id].map { $0 != BluetoothManager.podStatusClear } - let isAlert = status != BluetoothManager.podStatusClear + let isAlert = alertByte != 0 lastPodStatusWord[id] = status - log.default("[POD-STATUS] %{public}@ status=%{public}@ (%{public}@) — connectionless detect", - id, status.hexadecimalString, isAlert ? "non-clear/ALERT" : "clear") - connectionDelegate?.omnipodLogDeviceEvent("[POD-STATUS] status=\(status.hexadecimalString) (\(isAlert ? "non-clear/ALERT" : "clear")) — connectionless detect") + let slotDesc = alertSet.isEmpty ? "none" : alertSet.map { String(describing: $0) }.joined(separator: ",") + log.default("[POD-STATUS] %{public}@ status=%{public}@ alertByte=0x%{public}02x b1=0x%{public}02x slots=[%{public}@] — connectionless detect", + id, status.hexadecimalString, alertByte, statusByte1, slotDesc) + connectionDelegate?.omnipodLogDeviceEvent("[POD-STATUS] status=\(status.hexadecimalString) alertByte=0x\(String(format: "%02x", alertByte)) b1=0x\(String(format: "%02x", statusByte1)) slots=[\(slotDesc)] — connectionless detect") if wasAlert != isAlert { - log.default("[POD-ALERT] %{public}@ → %{public}@ (from advertisement, no connect)", - id, isAlert ? "ALERT ACTIVE" : "CLEARED") - connectionDelegate?.omnipodLogDeviceEvent("[POD-ALERT] → \(isAlert ? "ALERT ACTIVE" : "CLEARED") (from advertisement, no connect)") + log.default("[POD-ALERT] %{public}@ → %{public}@ slots=[%{public}@] (from advertisement, no connect)", + id, isAlert ? "ALERT ACTIVE" : "CLEARED", slotDesc) + connectionDelegate?.omnipodLogDeviceEvent("[POD-ALERT] → \(isAlert ? "ALERT ACTIVE" : "CLEARED") slots=[\(slotDesc)] (from advertisement, no connect)") } } From f17a706d0f3a68d2359014a9a51ca499681c191d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 7 Jul 2026 14:02:11 -0500 Subject: [PATCH 49/92] fault-listener: compute alert transition on the firing byte (fix missed ALERT ACTIVE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resting advert is …0a00 (b1=0x0a: an expiration reminder is CONFIGURED; b3=0x00: nothing FIRING), not the fixed 00020000 clear. wasAlert compared the whole word to that fixed clear, so it was wrongly true and the 000a0000 -> 000a0008 (slot3 firing) transition never logged ALERT ACTIVE. Track the previous b3 (firing) byte and compute wasAlert from it, matching isAlert. Finding: b1 = alert configured, b3 = alert firing. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 0e687d0..7b2650b 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -861,7 +861,14 @@ extension BluetoothManager: CBCentralManagerDelegate { let alertByte: UInt8 = bytes.count >= 4 ? bytes[3] : 0 let statusByte1: UInt8 = bytes.count >= 2 ? bytes[1] : 0 let alertSet = AlertSet(rawValue: alertByte) - let wasAlert = lastPodStatusWord[id].map { $0 != BluetoothManager.podStatusClear } + // Alert state is the b3 AlertSet bitmask (which slots are FIRING). b1 carries a separate + // "alert configured" bit (0x08) + baseline 0x02, so the resting advert can be …0a00 with + // nothing firing — comparing the whole word against a fixed "clear" mis-flags that. Track the + // previous FIRING byte so transitions are computed on the same basis as isAlert. + let prevAlertByte = lastPodStatusWord[id].flatMap { d -> UInt8? in + let b = Array(d); return b.count >= 4 ? b[3] : nil + } ?? 0 + let wasAlert = prevAlertByte != 0 let isAlert = alertByte != 0 lastPodStatusWord[id] = status let slotDesc = alertSet.isEmpty ? "none" : alertSet.map { String(describing: $0) }.joined(separator: ",") From 5dd3a15a27ba96eea64430ebe420dc1565ee71d6 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 7 Jul 2026 14:05:03 -0500 Subject: [PATCH 50/92] fault-listener stage 2: surface a connectionless-detected alert to Loop On a firing alert transition from the advertisement, hop up (omnipodDidDetectAlert) and call getPodStatus: it connects on demand, reads the real pod status, and the existing podComms(didChange:) -> alertsChanged -> issueAlert path raises the proper Loop alert with accurate details. This is wake -> connect -> read -> raise: the low-power advert is the trigger, the connection provides the truth. Change-gated detection means it fires once per firing transition (no re-trigger while the alert persists). --- OmnipodKit/Bluetooth/BlePodComms.swift | 4 ++++ OmnipodKit/Bluetooth/BluetoothManager.swift | 13 +++++++++++++ OmnipodKit/PumpManager/OmniPumpManager.swift | 12 ++++++++++++ 3 files changed, 29 insertions(+) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index 8d88037..516f7c0 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -772,6 +772,10 @@ extension BlePodComms: OmniConnectionDelegate { delegate?.omnipodHeartbeatDidFire() } + func omnipodDidDetectAlert(slots: AlertSet) { + delegate?.omnipodDidDetectAlert(slots: slots) + } + func omnipodPeripheralWasRestored(manager: PeripheralManager) { if let podState = podState, manager.peripheral.identifier.uuidString == podState.bleIdentifier { log.bleDebug("omnipodPeripheralWasRestored for %@", manager.peripheral.identifier.uuidString) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 7b2650b..b80972d 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -95,11 +95,17 @@ protocol OmniConnectionDelegate: AnyObject { /// Tells the delegate a pump-provided heartbeat wake fired (a delayed-connect probe completed). /// The host (OmniPumpManager) turns this into pumpManagerBLEHeartbeatDidFire so Loop runs a cycle. func omnipodHeartbeatDidFire() + + /// Tells the delegate a pod alert was detected connectionlessly (from the advertisement). The host + /// connects on demand and reads the real pod status, which surfaces the alert to Loop via the + /// normal getPodStatus -> alertsChanged -> issueAlert path. `slots` is the decoded firing AlertSet. + func omnipodDidDetectAlert(slots: AlertSet) } extension OmniConnectionDelegate { func omnipodLogDeviceEvent(_ message: String) {} func omnipodHeartbeatDidFire() {} + func omnipodDidDetectAlert(slots: AlertSet) {} } @@ -879,6 +885,13 @@ extension BluetoothManager: CBCentralManagerDelegate { log.default("[POD-ALERT] %{public}@ → %{public}@ slots=[%{public}@] (from advertisement, no connect)", id, isAlert ? "ALERT ACTIVE" : "CLEARED", slotDesc) connectionDelegate?.omnipodLogDeviceEvent("[POD-ALERT] → \(isAlert ? "ALERT ACTIVE" : "CLEARED") slots=[\(slotDesc)] (from advertisement, no connect)") + if isAlert { + // Stage 2: a fault just started firing — connect on demand and read the real pod status + // so the alert surfaces to Loop (getPodStatus -> alertsChanged -> issueAlert). Fires once + // per firing transition (detection is change-gated), so it won't re-trigger while the + // same alert persists. + connectionDelegate?.omnipodDidDetectAlert(slots: alertSet) + } } } diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index 2c1aafc..3923283 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -382,6 +382,18 @@ public class OmniPumpManager: RileyLinkPumpManager { issueHeartbeatIfNeeded() } + func omnipodDidDetectAlert(slots: AlertSet) { + // A pod alert was detected connectionlessly (from the advertisement). Connect on demand and read + // the real pod status: getPodStatus surfaces newly-active alerts to Loop via alertsChanged -> + // issueAlert. This turns the low-power advert wake into a real Loop alert with accurate details. + logDeviceCommunication("[POD-ALERT] detected \(slots) from advertisement — fetching pod status to surface it", type: .connection) + getPodStatus(canOptimize: false) { result in + if case .failure(let error) = result { + self.log.error("omnipodDidDetectAlert: getPodStatus failed: %{public}@", String(describing: error)) + } + } + } + func omnipodPeripheralDidConnect(manager: PeripheralManager) { logDeviceCommunication("Pod connected \(manager.peripheral.identifier.uuidString)", type: .connection) notifyPodConnectionStateDidChange(isConnected: true) From 54193a376ffc94f3a2744c321631f7f31280abfd Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 7 Jul 2026 14:29:41 -0500 Subject: [PATCH 51/92] fault-listener: quiet re-wakes while an alert is active A persisting alert keeps the pod advertising C005, so the alarm scan would keep waking us (harmless -- re-processing is change-gated -- but it churns the radio and nudges the heartbeat probe). On a firing detection, stop the alarm scan and suppress its resume; lift the suppression when a connected status read shows all alerts cleared (alertsChanged newAlerts empty -> BlePodComms -> resumeAlarmScanAfterAlertsCleared). New faults are still caught within the heartbeat cadence while suppressed. --- OmnipodKit/Bluetooth/BlePodComms.swift | 6 +++++ OmnipodKit/Bluetooth/BluetoothManager.swift | 25 ++++++++++++++++++++ OmnipodKit/PumpManager/OmniPumpManager.swift | 4 ++++ 3 files changed, 35 insertions(+) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index 516f7c0..b9028b9 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -776,6 +776,12 @@ extension BlePodComms: OmniConnectionDelegate { delegate?.omnipodDidDetectAlert(slots: slots) } + /// Lift the fault-listener re-wake suppression once all pod alerts have cleared (OmniPumpManager + /// calls this from a connected status read). + func resumeAlarmScanAfterAlertsCleared() { + bluetoothManager?.resumeAlarmScanAfterAlertsCleared() + } + func omnipodPeripheralWasRestored(manager: PeripheralManager) { if let podState = podState, manager.peripheral.identifier.uuidString == podState.bleIdentifier { log.bleDebug("omnipodPeripheralWasRestored for %@", manager.peripheral.identifier.uuidString) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index b80972d..3bc0d89 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -126,6 +126,13 @@ class BluetoothManager: NSObject { /// Last-seen DASH advertisement status word per peripheral, for connectionless alert detection. private var lastPodStatusWord: [String: Data] = [:] + /// Re-wake quieting: true while a detected alert is being surfaced/active. A persisting alert keeps + /// the pod advertising `C005`, so the alarm scan would keep waking us (harmless — re-processing is + /// change-gated — but it churns the radio and nudges the heartbeat probe). We stop the alarm scan + /// once an alert is surfaced and resume it when all alerts clear (via a connected status read). + /// New faults are still caught within the heartbeat cadence while suppressed. managerQueue-isolated. + private var alarmScanSuppressed = false + /// Last advertisement timestamp per peripheral, to log inter-frame cadence (the DS-beacon-rate /// measurement the RE asked for — is there a usable periodic wake?). private var lastAdvSeen: [String: Date] = [:] @@ -728,12 +735,25 @@ class BluetoothManager: NSObject { /// Only when nothing is connected, so we never scan while a command is using the link. private func resumeScanIfNeeded() { guard BluetoothManager.advertisementMonitorEnabled || BluetoothManager.beaconCaptureEnabled || BluetoothManager.lowPowerMonitorEnabled else { return } + guard !alarmScanSuppressed else { return } // an alert is active — stay quiet (re-wake quieting) guard manager?.state == .poweredOn, !manager.isScanning else { return } guard !devices.contains(where: { $0.manager.peripheral.state == .connected || $0.manager.peripheral.state == .connecting }) else { return } log.default("[connectOnDemand] resuming scan after connect attempt") startScanning() } + /// Called (via BlePodComms) when a connected status read shows all pod alerts cleared: lift the + /// re-wake suppression and resume the connectionless alarm scan. + func resumeAlarmScanAfterAlertsCleared() { + managerQueue.async { [weak self] in + guard let self = self, self.alarmScanSuppressed else { return } + self.alarmScanSuppressed = false + self.log.default("[POD-ALERT] alerts cleared — resuming alarm scan") + self.connectionDelegate?.omnipodLogDeviceEvent("[POD-ALERT] alerts cleared — resuming alarm scan") + self.resumeScanIfNeeded() + } + } + // MARK: - Accessors func getConnectedDevices() -> [Omni] { @@ -891,6 +911,11 @@ extension BluetoothManager: CBCentralManagerDelegate { // per firing transition (detection is change-gated), so it won't re-trigger while the // same alert persists. connectionDelegate?.omnipodDidDetectAlert(slots: alertSet) + // Re-wake quieting: the pod will keep advertising C005 while the alert persists; stop the + // alarm scan now so it doesn't keep waking us. resumeAlarmScanAfterAlertsCleared() lifts + // this once a connected read shows the alerts cleared. + alarmScanSuppressed = true + if manager.isScanning { manager.stopScan() } } } } diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index 3923283..aa7637b 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -3087,6 +3087,10 @@ extension OmniPumpManager: PumpManager { for alert in removed { log.default("Alert slot cleared: %{public}@", String(describing: alert)) } + // Re-wake quieting: once all pod alerts have cleared, let the connectionless alarm scan resume. + if newAlerts.isEmpty { + (podComms as? BlePodComms)?.resumeAlarmScanAfterAlertsCleared() + } } private func getPumpManagerAlert(for podAlert: PodAlert, slot: AlertSlot) -> PumpManagerAlert? { From bb1553063a6b8576f2ccfa536cdbcf48c97d9459 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 7 Jul 2026 16:12:54 -0500 Subject: [PATCH 52/92] TEMP fault-capture build: wildcard scan + device-log full adverts (revert after) To capture a real pod fault's advertisement signature (occlusion via clamped cannula + large bolus): widen the idle scan to wildcard (beaconCapture on, lowPowerMonitor off) so a fault advert on any service UUID is heard; device-log each DISTINCT pod advert (svcUUIDs|mfg|connectable, deduped) so the fault frame lands in the Issue Report; disable foreground keep-alive and re-wake quieting in capture mode so the pod stays disconnected + advertising and the scan keeps recording through the fault. Test foreground with a BLE CGM present (heartbeat off). Revert all of this after the capture. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 34 ++++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 3bc0d89..ee51585 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -137,6 +137,10 @@ class BluetoothManager: NSObject { /// measurement the RE asked for — is there a usable periodic wake?). private var lastAdvSeen: [String: Date] = [:] + /// FAULT-CAPTURE: last full advert (svcUUIDs|mfg) device-logged per peripheral, so we record each + /// DISTINCT advert once (captures the fault transition without flooding the device log). + private var lastLoggedAdvKey: [String: String] = [:] + /// Isolated to `managerQueue` private var discoveryModeEnabled: Bool = false @@ -198,7 +202,7 @@ class BluetoothManager: NSObject { /// normal↔triggered-alert diff pins the alarm-code offsets + the stable background-filter UUID. /// Heavy (wildcard foreground scan) — field-test only; revert before merge. static var beaconCaptureEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.beaconCaptureEnabled") as? Bool ?? false + UserDefaults.standard.object(forKey: "OmnipodKit.beaconCaptureEnabled") as? Bool ?? true // FAULT-CAPTURE: wildcard scan to catch a fault advert on any UUID (revert after) } /// Prefix of the DASH alarm/beacon 128-bit service UUID (per RE spec §3). @@ -215,7 +219,7 @@ class BluetoothManager: NSObject { /// so we get a background fault wake but don't wake on every normal advert. Connect-on-demand /// (its own light helper scan) handles command connects. static var lowPowerMonitorEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.lowPowerMonitorEnabled") as? Bool ?? true + UserDefaults.standard.object(forKey: "OmnipodKit.lowPowerMonitorEnabled") as? Bool ?? false // FAULT-CAPTURE: off so the wildcard beacon scan runs instead of the C005-only alarm scan (revert after) } /// Field-test master switch for the IDLE scan (startScanning). ON = run the alarm-filtered @@ -289,7 +293,9 @@ class BluetoothManager: NSObject { /// live and in-app commands are instant. On background we disconnect and resume the heartbeat probe. private var isAppForeground = false /// Cross-queue read for PeripheralManager's idle-disconnect (benign bool race, like everForeground). - var appIsForeground: Bool { isAppForeground } + /// In fault-capture mode keep-alive is off so the pod stays disconnected + advertising for the + /// wildcard scan to record a fault advert. + var appIsForeground: Bool { isAppForeground && !BluetoothManager.beaconCaptureEnabled } /// True once this PROCESS has ever been foregrounded. A [delayedConnect] with everFg=false means /// iOS ran this process entirely in the background — proof of a background wake/relaunch the user @@ -657,7 +663,7 @@ class BluetoothManager: NSObject { private func enterForeground() { dispatchPrecondition(condition: .onQueue(managerQueue)) isAppForeground = true - if let peripheral = keepAlivePeripheral, peripheral.state == .disconnected { + if !BluetoothManager.beaconCaptureEnabled, let peripheral = keepAlivePeripheral, peripheral.state == .disconnected { log.default("[connectOnDemand] foreground — pre-connecting for keep-alive") connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] foreground — pre-connecting for keep-alive") beginCommandConnect(peripheral) @@ -913,9 +919,12 @@ extension BluetoothManager: CBCentralManagerDelegate { connectionDelegate?.omnipodDidDetectAlert(slots: alertSet) // Re-wake quieting: the pod will keep advertising C005 while the alert persists; stop the // alarm scan now so it doesn't keep waking us. resumeAlarmScanAfterAlertsCleared() lifts - // this once a connected read shows the alerts cleared. - alarmScanSuppressed = true - if manager.isScanning { manager.stopScan() } + // this once a connected read shows the alerts cleared. Skipped in fault-capture mode so + // the wildcard scan keeps recording adverts through a fault. + if !BluetoothManager.beaconCaptureEnabled { + alarmScanSuppressed = true + if manager.isScanning { manager.stopScan() } + } } } } @@ -946,6 +955,15 @@ extension BluetoothManager: CBCentralManagerDelegate { log.default("%{public}@ %{public}@ dt=%{public}@s rssi=%{public}@ state=%{public}@ connectable=%{public}@ name=%{public}@ svcUUIDs=[%{public}@] mfg=%{public}@ svcData=%{public}@", tag, peripheral.identifier.uuidString, dt, RSSI, String(describing: peripheral.state.rawValue), String(describing: connectable), name.isEmpty ? "-" : name, svcUUIDs.isEmpty ? "-" : svcUUIDs, mfg, svcData) + // FAULT-CAPTURE: record each DISTINCT advert to the device log (so a fault advert lands in the + // Issue Report). Deduped by svcUUIDs|mfg so we log a change once, not every ~1Hz frame. + if BluetoothManager.beaconCaptureEnabled { + let advKey = "\(svcUUIDs)|\(mfg)|conn=\(String(describing: connectable))" + if lastLoggedAdvKey[peripheral.identifier.uuidString] != advKey { + lastLoggedAdvKey[peripheral.identifier.uuidString] = advKey + connectionDelegate?.omnipodLogDeviceEvent("\(tag) svcUUIDs=[\(svcUUIDs.isEmpty ? "-" : svcUUIDs)] mfg=\(mfg) connectable=\(String(describing: connectable)) svcData=\(svcData)") + } + } } else if let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data, BluetoothManager.advertisementMonitorEnabled, !BluetoothManager.beaconCaptureEnabled { // Suppressed in beacon-capture (wildcard) mode — this fired for every nearby BLE device. @@ -1073,7 +1091,7 @@ extension BluetoothManager: CBCentralManagerDelegate { autoReconnect(peripheral) } delayedProbeInFlight = false - if isAppForeground && commandConnectInFlight { + if appIsForeground && commandConnectInFlight { // Foreground keep-alive: an unintended drop while we want to stay connected (a deliberate // background/idle disconnect clears commandConnectInFlight first, so it won't reconnect). log.default("[connectOnDemand] foreground keep-alive — reconnecting after drop") From 958ac1903047db6cf137480ca543c213bba5a412 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 7 Jul 2026 17:14:52 -0500 Subject: [PATCH 53/92] fault-listener: detect pod faults from the advertisement (b2 fault code) + revert capture build Captured occlusion fault decode: the advert status word's b2 byte is the FaultEventCode (0x00 in every non-fault state; 0x14 Occluded on the captured fault -- word 00141400, connected read confirmed '0x14 Occluded'), and the 2nd service UUID goes C001->C005(alert)->C00A(fault). Our detector only checked b3 (AlertSet), so it missed the fault (logged slots=[none]) and Loop stayed unaware until a manual status fetch. - Detect b2 != 0 as a fault: decode FaultEventCode, log [POD-FAULT], and run the same wake->connect->read->raise (getPodStatus reads the fault -> OmniPumpManager fires the pod-fault alarm). - Add C00A to alarmServiceUUIDs so a fault wakes the low-power scan. - Revert the temp fault-capture flags (beaconCapture off, lowPowerMonitor on); the capture-mode-gated code is now inert (to be removed in the merge cleanup). --- OmnipodKit/Bluetooth/BluetoothManager.swift | 78 +++++++++++++-------- 1 file changed, 47 insertions(+), 31 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index ee51585..e339589 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -202,7 +202,7 @@ class BluetoothManager: NSObject { /// normal↔triggered-alert diff pins the alarm-code offsets + the stable background-filter UUID. /// Heavy (wildcard foreground scan) — field-test only; revert before merge. static var beaconCaptureEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.beaconCaptureEnabled") as? Bool ?? true // FAULT-CAPTURE: wildcard scan to catch a fault advert on any UUID (revert after) + UserDefaults.standard.object(forKey: "OmnipodKit.beaconCaptureEnabled") as? Bool ?? false } /// Prefix of the DASH alarm/beacon 128-bit service UUID (per RE spec §3). @@ -219,7 +219,7 @@ class BluetoothManager: NSObject { /// so we get a background fault wake but don't wake on every normal advert. Connect-on-demand /// (its own light helper scan) handles command connects. static var lowPowerMonitorEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.lowPowerMonitorEnabled") as? Bool ?? false // FAULT-CAPTURE: off so the wildcard beacon scan runs instead of the C005-only alarm scan (revert after) + UserDefaults.standard.object(forKey: "OmnipodKit.lowPowerMonitorEnabled") as? Bool ?? true } /// Field-test master switch for the IDLE scan (startScanning). ON = run the alarm-filtered @@ -263,8 +263,11 @@ class BluetoothManager: NSObject { /// deviceId GUESSED = pod address 179F0CF1 (TT 02=AS, 03=AST). UNCONFIRMED — no CE1F923D frame /// has appeared in field capture; harmless if wrong (just won't match). Fix deviceId/byte-order /// from a real [BEACON] capture before relying on these. + /// - `C00A`: CONFIRMED fault 2nd-UUID (captured occlusion 0x14 — the pod's 2nd service UUID went + /// C001(normal)→C005(alert)→C00A(fault)). Include it so a fault wakes the low-power scan. static let alarmServiceUUIDs: [CBUUID] = [ CBUUID(string: "C005"), + CBUUID(string: "C00A"), CBUUID(string: "CE1F923D-C539-48EA-7300-0A179F0CF102"), CBUUID(string: "CE1F923D-C539-48EA-7300-0A179F0CF103"), ] @@ -889,43 +892,56 @@ extension BluetoothManager: CBCentralManagerDelegate { // The 4-byte status word is [b0 b1 b2 b3]. b3 is the AlertSet bitmask (bit N = slot N firing); // e.g. clear=…00, expiration-reminder(slot3)=…08. b1 carries a baseline 0x02 (slot1 "NotUsed") // plus the same alert bit, so we log it too as a cross-check while enumerating alert types. + // The 4-byte status word is [b0 b1 b2 b3]: + // - b3 = AlertSet bitmask (bit N = alert slot N FIRING); e.g. expiration-reminder(slot3)=0x08. + // - b2 = FAULT code (0x00 in every non-fault state; = FaultEventCode on a fault, e.g. occlusion + // 0x14 — confirmed by a captured occlusion: word 00141400, connected read "0x14 Occluded"). + // - b1 = an "alert configured"/current-alarm byte (baseline 0x02, 0x0a with a reminder set, + // and the fault code on a fault) — logged as a cross-check only. let bytes = Array(status) let alertByte: UInt8 = bytes.count >= 4 ? bytes[3] : 0 + let faultByte: UInt8 = bytes.count >= 3 ? bytes[2] : 0 let statusByte1: UInt8 = bytes.count >= 2 ? bytes[1] : 0 let alertSet = AlertSet(rawValue: alertByte) - // Alert state is the b3 AlertSet bitmask (which slots are FIRING). b1 carries a separate - // "alert configured" bit (0x08) + baseline 0x02, so the resting advert can be …0a00 with - // nothing firing — comparing the whole word against a fixed "clear" mis-flags that. Track the - // previous FIRING byte so transitions are computed on the same basis as isAlert. - let prevAlertByte = lastPodStatusWord[id].flatMap { d -> UInt8? in - let b = Array(d); return b.count >= 4 ? b[3] : nil - } ?? 0 + let prevBytes = lastPodStatusWord[id].map { Array($0) } + let prevAlertByte: UInt8 = (prevBytes?.count ?? 0) >= 4 ? prevBytes![3] : 0 + let prevFaultByte: UInt8 = (prevBytes?.count ?? 0) >= 3 ? prevBytes![2] : 0 let wasAlert = prevAlertByte != 0 let isAlert = alertByte != 0 + let wasFault = prevFaultByte != 0 + let isFault = faultByte != 0 lastPodStatusWord[id] = status let slotDesc = alertSet.isEmpty ? "none" : alertSet.map { String(describing: $0) }.joined(separator: ",") - log.default("[POD-STATUS] %{public}@ status=%{public}@ alertByte=0x%{public}02x b1=0x%{public}02x slots=[%{public}@] — connectionless detect", - id, status.hexadecimalString, alertByte, statusByte1, slotDesc) - connectionDelegate?.omnipodLogDeviceEvent("[POD-STATUS] status=\(status.hexadecimalString) alertByte=0x\(String(format: "%02x", alertByte)) b1=0x\(String(format: "%02x", statusByte1)) slots=[\(slotDesc)] — connectionless detect") - if wasAlert != isAlert { - log.default("[POD-ALERT] %{public}@ → %{public}@ slots=[%{public}@] (from advertisement, no connect)", - id, isAlert ? "ALERT ACTIVE" : "CLEARED", slotDesc) - connectionDelegate?.omnipodLogDeviceEvent("[POD-ALERT] → \(isAlert ? "ALERT ACTIVE" : "CLEARED") slots=[\(slotDesc)] (from advertisement, no connect)") - if isAlert { - // Stage 2: a fault just started firing — connect on demand and read the real pod status - // so the alert surfaces to Loop (getPodStatus -> alertsChanged -> issueAlert). Fires once - // per firing transition (detection is change-gated), so it won't re-trigger while the - // same alert persists. - connectionDelegate?.omnipodDidDetectAlert(slots: alertSet) - // Re-wake quieting: the pod will keep advertising C005 while the alert persists; stop the - // alarm scan now so it doesn't keep waking us. resumeAlarmScanAfterAlertsCleared() lifts - // this once a connected read shows the alerts cleared. Skipped in fault-capture mode so - // the wildcard scan keeps recording adverts through a fault. - if !BluetoothManager.beaconCaptureEnabled { - alarmScanSuppressed = true - if manager.isScanning { manager.stopScan() } - } - } + let faultDesc = isFault ? String(describing: FaultEventCode(rawValue: faultByte)) : "none" + log.default("[POD-STATUS] %{public}@ status=%{public}@ alertByte=0x%{public}02x faultByte=0x%{public}02x b1=0x%{public}02x slots=[%{public}@] fault=%{public}@ — connectionless detect", + id, status.hexadecimalString, alertByte, faultByte, statusByte1, slotDesc, faultDesc) + connectionDelegate?.omnipodLogDeviceEvent("[POD-STATUS] status=\(status.hexadecimalString) alertByte=0x\(String(format: "%02x", alertByte)) faultByte=0x\(String(format: "%02x", faultByte)) slots=[\(slotDesc)] fault=\(faultDesc) — connectionless detect") + + // A pod FAULT just appeared (b2 went non-zero): the pod has stopped delivery. Surface it. + if !wasFault && isFault { + log.default("[POD-FAULT] %{public}@ → FAULT 0x%{public}02x (%{public}@) (from advertisement, no connect)", id, faultByte, faultDesc) + connectionDelegate?.omnipodLogDeviceEvent("[POD-FAULT] → FAULT 0x\(String(format: "%02x", faultByte)) (\(faultDesc)) (from advertisement, no connect)") + surfacePodConditionAndQuiet(alertSet: alertSet) + } else if !wasAlert && isAlert { + // An alert slot just started firing. + log.default("[POD-ALERT] %{public}@ → ALERT ACTIVE slots=[%{public}@] (from advertisement, no connect)", id, slotDesc) + connectionDelegate?.omnipodLogDeviceEvent("[POD-ALERT] → ALERT ACTIVE slots=[\(slotDesc)] (from advertisement, no connect)") + surfacePodConditionAndQuiet(alertSet: alertSet) + } else if (wasAlert && !isAlert) || (wasFault && !isFault) { + log.default("[POD-ALERT] %{public}@ → CLEARED slots=[%{public}@] (from advertisement, no connect)", id, slotDesc) + connectionDelegate?.omnipodLogDeviceEvent("[POD-ALERT] → CLEARED slots=[\(slotDesc)] (from advertisement, no connect)") + } + } + + /// Connect on demand + read the real pod status so a connectionless-detected alert/fault surfaces to + /// Loop (getPodStatus -> alertsChanged/issueAlert or fault handling), then quiet the alarm scan while + /// the condition persists (re-wake quieting; lifted by resumeAlarmScanAfterAlertsCleared()). + private func surfacePodConditionAndQuiet(alertSet: AlertSet) { + dispatchPrecondition(condition: .onQueue(managerQueue)) + connectionDelegate?.omnipodDidDetectAlert(slots: alertSet) + if !BluetoothManager.beaconCaptureEnabled { // capture mode keeps the wildcard scan recording + alarmScanSuppressed = true + if manager.isScanning { manager.stopScan() } } } From 83a5da57b10355631d85deb7f0bccf67930322d9 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 7 Jul 2026 17:39:11 -0500 Subject: [PATCH 54/92] =?UTF-8?q?fix:=20pairing=20broken=20by=20alarm-scan?= =?UTF-8?q?=20filter=20=E2=80=94=20discovery=20mode=20must=20scan=20the=20?= =?UTF-8?q?pairing=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit discoverPods set discoveryModeEnabled and called startScanning, but startScanning checked lowPowerMonitorEnabled first and scanned the alarm UUIDs (C005/C00A) — never the pod's main advertisement service — so a new/ unpaired pod was never found. Make discoveryModeEnabled take precedence in startScanning (scan podScanServiceUUID + allowDuplicates), ahead of both the low-power alarm scan and the scanningEnabled guard. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index e339589..536d2b8 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -702,13 +702,23 @@ class BluetoothManager: NSObject { } private func startScanning() { + let serviceUUID: CBUUID = podScanServiceUUID + let services: [CBUUID]? + let options: [String: Any] + if discoveryModeEnabled { + // Pairing: scan for the pod's main advertisement service so a new/unpaired pod is found. + // MUST take precedence over the low-power alarm scan (which filters on C005/C00A and would + // never see a pairing pod) and over scanningEnabled (pairing has to scan regardless). + services = [serviceUUID] + options = [CBCentralManagerScanOptionAllowDuplicatesKey: true] + log.default("Start scanning (discovery/pairing filter=%{public}@)", serviceUUID.uuidString) + manager.scanForPeripherals(withServices: services, options: options) + return + } guard BluetoothManager.scanningEnabled else { log.default("[connectOnDemand] scanning disabled — not starting a scan (scan-free connect mode)") return } - let serviceUUID: CBUUID = podScanServiceUUID - let services: [CBUUID]? - let options: [String: Any] if BluetoothManager.lowPowerMonitorEnabled { // Option 3: wake only on an alarm-state advertisement. Filter on the alarm UUID(s), no // allowDuplicates. Takes precedence over the monitor/beacon scans. From 784ae47215936dc511cb7a8b7f5a90e35624647b Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 7 Jul 2026 17:50:42 -0500 Subject: [PATCH 55/92] fix pairing: suppress heartbeat probe during discovery + log the pairing scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After discarding the faulted pod, the connect-on-demand heartbeat probe kept churning (issuing StartDelay connects, resuming) against the stale old-pod device even with podState nil, clobbering the pairing discovery scan. Gate issueDelayedConnectProbe on !discoveryModeEnabled, and quiet any in-flight probe/scan when discoverPods starts. Device-log the pairing flow ([pairing] discoverPods / scan started / heard pod pairable=…) so the report shows whether discovery runs and hears the new pod. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 536d2b8..69a54c9 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -356,6 +356,9 @@ class BluetoothManager: NSObject { /// Issue a connect with CBConnectPeripheralOptionStartDelayKey and record the time, for the /// timed-wake experiment. iOS holds the request for `delayedConnectProbeSeconds`, then connects. private func issueDelayedConnectProbe(_ peripheral: CBPeripheral) { + // Never run the heartbeat probe during pairing — its connect/disconnect churn clobbers the + // discovery scan (this blocked pairing a new pod after the old one was discarded). + guard !discoveryModeEnabled else { return } guard delayedConnectProbeActive, !delayedProbeInFlight, !commandConnectInFlight, peripheral.state == .disconnected else { return } let delay = BluetoothManager.delayedConnectProbeSeconds @@ -553,6 +556,11 @@ class BluetoothManager: NSObject { // We will attempt to connect to all pairable devices when in discovery mode discoveryModeEnabled = true + connectionDelegate?.omnipodLogDeviceEvent("[pairing] discoverPods — scanning for a pairable pod") + // Quiet any in-flight heartbeat probe / stale connect churn so it doesn't clobber discovery. + delayedProbeInFlight = false + alarmScanSuppressed = false + manager.stopScan() for device in devices { let peripheral = device.manager.peripheral if peripheral.state == .disconnected || peripheral.state == .disconnecting { @@ -712,6 +720,7 @@ class BluetoothManager: NSObject { services = [serviceUUID] options = [CBCentralManagerScanOptionAllowDuplicatesKey: true] log.default("Start scanning (discovery/pairing filter=%{public}@)", serviceUUID.uuidString) + connectionDelegate?.omnipodLogDeviceEvent("[pairing] scan started (filter=\(serviceUUID.uuidString))") manager.scanForPeripherals(withServices: services, options: options) return } @@ -1021,7 +1030,10 @@ extension BluetoothManager: CBCentralManagerDelegate { if let podAdvertisement = PodAdvertisement(advertisementData, podType: podType) { addPeripheral(peripheral, podAdvertisement: podAdvertisement) - + + if discoveryModeEnabled { + connectionDelegate?.omnipodLogDeviceEvent("[pairing] heard pod \(peripheral.identifier.uuidString) pairable=\(podAdvertisement.pairable) state=\(peripheral.state.rawValue)") + } if discoveryModeEnabled && peripheral.state == .disconnected && podAdvertisement.pairable { // Connect to any pairable device, during discovery log.default("Connecting to pairable device %{public} in discovery mode", peripheral) From 07e83a4e88498d877516c3eba687b59f37194257 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 7 Jul 2026 17:56:34 -0500 Subject: [PATCH 56/92] fix pairing: no heartbeat probe without an active pod + stop scan for the pairing connect Report showed the new pod heard + pairable=true but stuck in .connecting, while the heartbeat probe kept issuing StartDelay connects in the noPod state. Two causes: - keepAlivePeripheral (and setProvidesHeartbeat) fell back to devices.first when autoConnectIDs was empty, so the probe churned against the discarded pod. Return nil with no active pod; gate the didDisconnect/didFailToConnect probe re-arm on autoConnectIDs.contains(peripheral). - The allowDuplicates discovery scan starved the pairing connect (wedged it in .connecting). Stop the scan as soon as we hear the target pairable pod, then connect; if already mid-connect, stopping the scan lets it complete. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 32 +++++++++++++-------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 69a54c9..f4ecec0 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -328,9 +328,9 @@ class BluetoothManager: NSObject { self.log.default("[heartbeat] pid=%{public}d providesHeartbeat=%{public}@", pid, String(enabled)) self.connectionDelegate?.omnipodLogDeviceEvent("[heartbeat] pid=\(pid) providesHeartbeat=\(enabled)") if enabled { - // Kick off the delayed-connect loop against the known pod (prefer an autoconnect one). - let device = self.devices.first(where: { self.autoConnectIDs.contains($0.manager.peripheral.identifier.uuidString) }) ?? self.devices.first - if let peripheral = device?.manager.peripheral { + // Kick off the delayed-connect loop against the known autoconnect pod (nil if no active + // pod — don't probe a stale/discarded device). + if let peripheral = self.keepAlivePeripheral { self.issueDelayedConnectProbe(peripheral) } } else { @@ -662,10 +662,11 @@ class BluetoothManager: NSObject { connectViaFreshDiscovery(peripheral) } - /// The known/autoconnect pod peripheral, for foreground keep-alive and heartbeat. + /// The known/autoconnect pod peripheral, for foreground keep-alive and heartbeat. Returns nil when + /// there is no active pod (autoConnectIDs empty) — do NOT fall back to a stale device, or the + /// heartbeat probe churns against the discarded pod and clobbers pairing a new one. private var keepAlivePeripheral: CBPeripheral? { - let device = devices.first(where: { autoConnectIDs.contains($0.manager.peripheral.identifier.uuidString) }) ?? devices.first - return device?.manager.peripheral + return devices.first(where: { autoConnectIDs.contains($0.manager.peripheral.identifier.uuidString) })?.manager.peripheral } /// App entered the foreground: keep the pod connected so connection-gated UI is live and commands @@ -1034,10 +1035,17 @@ extension BluetoothManager: CBCentralManagerDelegate { if discoveryModeEnabled { connectionDelegate?.omnipodLogDeviceEvent("[pairing] heard pod \(peripheral.identifier.uuidString) pairable=\(podAdvertisement.pairable) state=\(peripheral.state.rawValue)") } - if discoveryModeEnabled && peripheral.state == .disconnected && podAdvertisement.pairable { - // Connect to any pairable device, during discovery - log.default("Connecting to pairable device %{public} in discovery mode", peripheral) - timedConnect(peripheral) // pairing — an explicit connect, not auto-reconnect + if discoveryModeEnabled && podAdvertisement.pairable { + // We've heard our target pairable pod — stop the discovery scan so it doesn't starve the + // connect (an active allowDuplicates scan wedges the connect in .connecting, which is + // what stalled pairing), then connect if it's disconnected. If it's already mid-connect, + // stopping the scan lets that connect complete. + if manager.isScanning { manager.stopScan() } + if peripheral.state == .disconnected { + log.default("Connecting to pairable device %{public}@ in discovery mode", peripheral) + connectionDelegate?.omnipodLogDeviceEvent("[pairing] connecting to pairable pod \(peripheral.identifier.uuidString)") + timedConnect(peripheral) // pairing — an explicit connect, not auto-reconnect + } } else if autoConnectIDs.contains(peripheral.identifier.uuidString) && peripheral.state == .disconnected { log.debug("Reonnecting to autoconnect device") autoReconnect(peripheral) @@ -1140,7 +1148,7 @@ extension BluetoothManager: CBCentralManagerDelegate { // probe alongside it. The two coexist — the scan is light and issueDelayedConnectProbe no // longer stops it. Re-arm the probe only when idle (never while a command owns the link). resumeScanIfNeeded() - if delayedConnectProbeActive && !commandConnectInFlight { + if delayedConnectProbeActive && !commandConnectInFlight && autoConnectIDs.contains(peripheral.identifier.uuidString) { issueDelayedConnectProbe(peripheral) } } @@ -1166,7 +1174,7 @@ extension BluetoothManager: CBCentralManagerDelegate { } delayedProbeInFlight = false resumeScanIfNeeded() // keep the fault-listener alarm scan running while idle - if delayedConnectProbeActive && !commandConnectInFlight { + if delayedConnectProbeActive && !commandConnectInFlight && autoConnectIDs.contains(peripheral.identifier.uuidString) { issueDelayedConnectProbe(peripheral) // re-arm so a failed connect doesn't stall the loop } } From b6ba0cd7c0200c59ffc1eea15e898f46650455f2 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 7 Jul 2026 18:03:50 -0500 Subject: [PATCH 57/92] prune discarded pod from devices[] on disconnectFromDevice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit devices[] was append-only (long-standing, in loop-next-dev too) — a discarded pod's device lingered forever, which our heartbeat/keep-alive machinery could pick up (via the old devices.first fallback) and which discoverPods still timed-connects during pairing. On discard, cancel the connection, remove the device from devices[], and clear delayedProbeInFlight. Closes the underlying issue rather than just the symptom. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index f4ecec0..0d0007d 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -519,6 +519,20 @@ class BluetoothManager: NSObject { func disconnectFromDevice(uuidString: String) { managerQueue.async { self.autoConnectIDs.remove(uuidString) + // Prune the discarded pod from devices[] (otherwise append-only) and drop any connection, so + // a stale device can't be picked up later by the heartbeat/keep-alive machinery or churned + // while pairing a new pod. (devices[] never being pruned is long-standing; this closes it.) + if let idx = self.devices.firstIndex(where: { $0.manager.peripheral.identifier.uuidString == uuidString }) { + let peripheral = self.devices[idx].manager.peripheral + if peripheral.state == .connected || peripheral.state == .connecting { + self.manager.cancelPeripheralConnection(peripheral) + } + self.devices.remove(at: idx) + self.log.default("Removed discarded pod %{public}@ from devices", uuidString) + self.connectionDelegate?.omnipodLogDeviceEvent("[pairing] removed discarded pod \(uuidString) from devices") + } + // Quiet any heartbeat probe that was driving off the (now-discarded) pod. + self.delayedProbeInFlight = false } } From 9a522a62ff92ec9aadcd87ec373c1ce8e23eeac1 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 7 Jul 2026 18:48:40 -0500 Subject: [PATCH 58/92] fault: report the incomplete dose to Loop immediately (not ~30s later) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a fault-detecting getPodStatus, handlePodFault finalizes the in-progress bolus in podState (delivered = programmed - bolusNotDelivered) synchronously, but getStatus() throws on a fault so status stayed nil and the dose flush (session.dosesForStorage -> store) was gated behind 'if status != nil' — skipped. The partial dose then sat unflushed until a later session (~30s observed), and post-fault getPodStatus early-returns at the fault==nil guard, so nothing flushed it promptly. Loop's IOB lagged the fault for that window. Flush whenever we actually read status (new didReadStatus flag) rather than only when status != nil, so the incomplete dose reaches Loop in the same session that reads the fault. dosesForStorage is a no-op with nothing new; the canOptimize skip path is unchanged. --- OmnipodKit/PumpManager/OmniPumpManager.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index aa7637b..2800309 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -1577,18 +1577,25 @@ extension OmniPumpManager { let optimizeInterval = TimeInterval(seconds: 115) let timeSinceLastResponse = -(self.state.podState?.podTimeUpdated ?? .distantPast).timeIntervalSinceNow let status: StatusResponse? + let didReadStatus: Bool if canOptimize && timeSinceLastResponse < optimizeInterval { self.log.debug("### skipping getStatus() with last status %@ ago", timeSinceLastResponse.timeIntervalStr) status = nil + didReadStatus = false } else { status = try? session.getStatus(noSeqGetStatus: true) + didReadStatus = true } // Silence any pending acknowledged alerts silenceAcknowledgedAlerts() - // If we have new status, store the dosesForStorage which updates lastPumpDataReportDate - if status != nil { + // Flush doses to Loop whenever we actually read (or attempted to read) status — NOT only when + // status != nil. A pod fault makes getStatus() throw (status stays nil), but handlePodFault has + // already finalized the in-progress bolus in podState (delivered = programmed − bolusNotDelivered). + // Without this, the incomplete dose sat unflushed until a later session (~30s), so Loop's IOB + // lagged the fault. dosesForStorage() is a no-op when there's nothing new. + if didReadStatus { session.dosesForStorage() { (doses) -> Bool in return store(doses: doses, in: session) } From 05f972db38bd5c470595524716d2c2f60d3fb7da Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 7 Jul 2026 19:47:59 -0500 Subject: [PATCH 59/92] EXPERIMENT (revert after): suppress heartbeat probe to test if StartDelay starves the alarm scan Add heartbeatProbeSuppressed flag (default ON this build) that forces delayedConnectProbeActive=false, so the idle alarm scan runs with NO pending StartDelay connect. If deep-idle alert detection is much faster than the ~5-min baseline (probe on), a pending connect was starving the background scan's advert delivery. If detection only fires after APP FOREGROUND, the scan alone doesn't wake us in deep idle (the probe was the wake). Revert after measuring. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 0d0007d..b6fbbd0 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -250,6 +250,15 @@ class BluetoothManager: NSObject { UserDefaults.standard.object(forKey: "OmnipodKit.delayedConnectProbeEnabled") as? Bool ?? false } + /// EXPERIMENT (revert after): suppress the delayed-connect heartbeat probe entirely, so the idle + /// alarm scan runs with NO pending StartDelay connect. Tests whether a pending connect starves the + /// background scan's advert delivery (the suspected cause of ~5-min deep-idle detection latency). + /// Default ON for this test build. NOTE: with no probe and no BLE CGM, Loop gets no periodic wake — + /// fine for measuring alert-detection latency (the detection itself triggers getPodStatus). + static var heartbeatProbeSuppressed: Bool { + UserDefaults.standard.object(forKey: "OmnipodKit.heartbeatProbeSuppressed") as? Bool ?? true + } + /// Start delay (seconds) for the delayed-connect probe. Note the real wake lands at StartDelay + /// an iOS reacquisition tail (~40s observed), so 300 → wake ~340s. static var delayedConnectProbeSeconds: Int { @@ -315,7 +324,8 @@ class BluetoothManager: NSObject { /// The delayed-connect loop is active when the host requests a heartbeat OR the manual test flag /// is set. Isolated to managerQueue (all probe call sites run there). private var delayedConnectProbeActive: Bool { - heartbeatEnabled || BluetoothManager.delayedConnectProbeEnabled + if BluetoothManager.heartbeatProbeSuppressed { return false } // EXPERIMENT: scan-only, no probe + return heartbeatEnabled || BluetoothManager.delayedConnectProbeEnabled } /// Enable/disable the pump-provided heartbeat (delayed-connect loop). Driven by From 3c67725139846c250063cc4a0f3c07b860ed493f Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 7 Jul 2026 20:06:17 -0500 Subject: [PATCH 60/92] EXPERIMENT (revert after): test alert fires ~5min out so it lands in deep idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 60s fuse fired while the app was still warm, so the probe-off test never reached deep idle. Fire ~5min out (and device-log the scheduled fire time) so the app is fully background-coalesced when the alert fires — a clean measure of whether the alarm scan wakes us without the StartDelay probe. --- OmnipodKit/PumpManager/OmniPumpManager.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index 81d6abd..18c79b4 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -1871,12 +1871,13 @@ extension OmniPumpManager { case .success(let session): self.handleSilencePodEnd(session: session) let podTime = self.podTime - let alertPodTime = podTime + TimeInterval(seconds: 60) // fire ~60s from now (mirrors updateExpirationReminder math) + let alertPodTime = podTime + TimeInterval(seconds: 300) // EXPERIMENT: fire ~5min out so the app is DEEP-idle when it fires (measure background scan latency) let testAlert = PodAlert.expirationReminder(offset: podTime, absAlertTime: alertPodTime, silent: false) do { let beepBlock = self.beepMessageBlock(beepType: .beep) let _ = try session.configureAlerts([testAlert], beepBlock: beepBlock) - self.log.default("[testAlert] scheduled expirationReminder to fire in ~60s (podTime=%{public}@, alertPodTime=%{public}@)", podTime.timeIntervalStr, alertPodTime.timeIntervalStr) + self.log.default("[testAlert] scheduled expirationReminder to fire in ~5min (podTime=%{public}@, alertPodTime=%{public}@)", podTime.timeIntervalStr, alertPodTime.timeIntervalStr) + self.logDeviceCommunication("[testAlert] scheduled expirationReminder to fire in ~5min (alertPodTime=\(alertPodTime.timeIntervalStr))", type: .connection) continuation.resume() } catch { continuation.resume(throwing: error) From dc62e2d04e1bd518e47174352a95a1517d91d7ce Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 8 Jul 2026 11:22:29 -0500 Subject: [PATCH 61/92] revert probe-suppression experiment: probe is the background wake, not an interferer Deep-idle test (probe off, 5-min fuse): the alert fired ~16:18:21 but was NOT detected until the app was reopened (16:18:48). So the alarm scan does not reliably wake a suspended app on its own (C005 is continuously advertised since a reminder is always configured, so iOS won't re-wake); the StartDelay probe is the reliable periodic background wake that flushes detection. Restore normal behavior: heartbeatProbeSuppressed defaults OFF (kept as a debug knob), test alert fuse back to 60s. The real latency lever is delayedConnectProbeSeconds. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 12 ++++++------ OmnipodKit/PumpManager/OmniPumpManager.swift | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index b6fbbd0..49a8471 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -250,13 +250,13 @@ class BluetoothManager: NSObject { UserDefaults.standard.object(forKey: "OmnipodKit.delayedConnectProbeEnabled") as? Bool ?? false } - /// EXPERIMENT (revert after): suppress the delayed-connect heartbeat probe entirely, so the idle - /// alarm scan runs with NO pending StartDelay connect. Tests whether a pending connect starves the - /// background scan's advert delivery (the suspected cause of ~5-min deep-idle detection latency). - /// Default ON for this test build. NOTE: with no probe and no BLE CGM, Loop gets no periodic wake — - /// fine for measuring alert-detection latency (the detection itself triggers getPodStatus). + /// Debug knob (default OFF): suppress the delayed-connect heartbeat probe so the idle alarm scan + /// runs with NO pending StartDelay connect. An experiment (2026-07-08) showed the probe is NOT + /// starving the scan — it's the reliable background WAKE: with it suppressed, a deep-idle alert + /// fired but was not detected until the app was reopened. So the alarm scan is best-effort and the + /// probe bounds detection latency. Left as a knob for future measurement; keep OFF in normal use. static var heartbeatProbeSuppressed: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.heartbeatProbeSuppressed") as? Bool ?? true + UserDefaults.standard.object(forKey: "OmnipodKit.heartbeatProbeSuppressed") as? Bool ?? false } /// Start delay (seconds) for the delayed-connect probe. Note the real wake lands at StartDelay + diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index 18c79b4..7a73c6c 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -1871,13 +1871,13 @@ extension OmniPumpManager { case .success(let session): self.handleSilencePodEnd(session: session) let podTime = self.podTime - let alertPodTime = podTime + TimeInterval(seconds: 300) // EXPERIMENT: fire ~5min out so the app is DEEP-idle when it fires (measure background scan latency) + let alertPodTime = podTime + TimeInterval(seconds: 60) // fire ~60s from now (mirrors updateExpirationReminder math) let testAlert = PodAlert.expirationReminder(offset: podTime, absAlertTime: alertPodTime, silent: false) do { let beepBlock = self.beepMessageBlock(beepType: .beep) let _ = try session.configureAlerts([testAlert], beepBlock: beepBlock) - self.log.default("[testAlert] scheduled expirationReminder to fire in ~5min (podTime=%{public}@, alertPodTime=%{public}@)", podTime.timeIntervalStr, alertPodTime.timeIntervalStr) - self.logDeviceCommunication("[testAlert] scheduled expirationReminder to fire in ~5min (alertPodTime=\(alertPodTime.timeIntervalStr))", type: .connection) + self.log.default("[testAlert] scheduled expirationReminder to fire in ~60s (podTime=%{public}@, alertPodTime=%{public}@)", podTime.timeIntervalStr, alertPodTime.timeIntervalStr) + self.logDeviceCommunication("[testAlert] scheduled expirationReminder to fire in ~60s (alertPodTime=\(alertPodTime.timeIntervalStr))", type: .connection) continuation.resume() } catch { continuation.resume(throwing: error) From f0ef35be5dde582dbe8b199a787a39f3d1d46f87 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 8 Jul 2026 11:31:14 -0500 Subject: [PATCH 62/92] ALERT-ADVERT CAPTURE (revert after): re-enable full-advert capture to diff configured vs firing UUIDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hypothesis: the TWI pod advertises C005 continuously (a reminder is always configured), so iOS discovers it once and won't re-wake a suspended app when the alert fires (only the mfg word b3 flips, not a service UUID). On In-Play the 2nd UUID went C001<->C005 on alert set, which WOULD wake. Capture the TWI pod's configured vs firing advert (wildcard scan + device-logged adverts, keep-alive off) to see if any service UUID changes on firing — if so, filter on it so the background scan wakes us. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 49a8471..126918b 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -202,7 +202,7 @@ class BluetoothManager: NSObject { /// normal↔triggered-alert diff pins the alarm-code offsets + the stable background-filter UUID. /// Heavy (wildcard foreground scan) — field-test only; revert before merge. static var beaconCaptureEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.beaconCaptureEnabled") as? Bool ?? false + UserDefaults.standard.object(forKey: "OmnipodKit.beaconCaptureEnabled") as? Bool ?? true // ALERT-ADVERT CAPTURE: wildcard scan + device-log full adverts to diff configured vs firing UUIDs (revert after) } /// Prefix of the DASH alarm/beacon 128-bit service UUID (per RE spec §3). From 27d7d3bebc35195bf880a7ca7e7c0098761d00d5 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 8 Jul 2026 11:58:41 -0500 Subject: [PATCH 63/92] FAULT-WAKE GROUND-TRUTH TEST (revert after): probe OFF, alarm scan only Suppress the heartbeat probe so the alarm scan (C005/C00A filter) is the ONLY possible background wake. If a deep-idle occlusion fault (C005->C00A service-UUID change) is detected while backgrounded, the scan's UUID-change wake works for faults (clean proof, no probe to confound it). Alarm scan active via lowPowerMonitor precedence; beaconCapture stays on for [ADV] logging of the C005->C00A transition. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 126918b..e0e081b 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -256,7 +256,7 @@ class BluetoothManager: NSObject { /// fired but was not detected until the app was reopened. So the alarm scan is best-effort and the /// probe bounds detection latency. Left as a knob for future measurement; keep OFF in normal use. static var heartbeatProbeSuppressed: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.heartbeatProbeSuppressed") as? Bool ?? false + UserDefaults.standard.object(forKey: "OmnipodKit.heartbeatProbeSuppressed") as? Bool ?? true // FAULT-WAKE GROUND-TRUTH TEST: probe OFF so only the alarm scan can wake us on a deep-idle fault (revert after) } /// Start delay (seconds) for the delayed-connect probe. Note the real wake lands at StartDelay + From 27adbdb7ce71d77c278fb6f6d8e11e030c66e58a Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 8 Jul 2026 14:02:20 -0500 Subject: [PATCH 64/92] fault-wake CONFIRMED in deep idle (probe off); switch to probe-ON to test coexistence Ground truth (probe off, ~10min deep idle): the alarm scan woke the suspended app on the C005->C00A fault UUID change and completed the full connectionless fault->Loop-alarm chain in the background in ~7s (detect 18:58:38 -> alarm 18:58:45), no heartbeat probe involved. So faults get fast connectionless detection via the scan; alerts (no UUID change) remain heartbeat-bounded. Now re-enable the probe to confirm probe + scan coexist without the pending connect starving the fault wake (production config). --- OmnipodKit/Bluetooth/BluetoothManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index e0e081b..4d37092 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -256,7 +256,7 @@ class BluetoothManager: NSObject { /// fired but was not detected until the app was reopened. So the alarm scan is best-effort and the /// probe bounds detection latency. Left as a knob for future measurement; keep OFF in normal use. static var heartbeatProbeSuppressed: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.heartbeatProbeSuppressed") as? Bool ?? true // FAULT-WAKE GROUND-TRUTH TEST: probe OFF so only the alarm scan can wake us on a deep-idle fault (revert after) + UserDefaults.standard.object(forKey: "OmnipodKit.heartbeatProbeSuppressed") as? Bool ?? false // probe ON: production config (probe + alarm scan coexisting) — confirm the scan still wakes fast on a deep-idle fault } /// Start delay (seconds) for the delayed-connect probe. Note the real wake lands at StartDelay + From 728a266275ed9b1f35709021505d9942507e2320 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 8 Jul 2026 19:10:23 -0500 Subject: [PATCH 65/92] =?UTF-8?q?EXPERIMENT=20(revert=20after):=20C00A-onl?= =?UTF-8?q?y=20fault=20scan=20+=20probe=20off=20=E2=80=94=20test=20fresh-d?= =?UTF-8?q?iscovery=20fault=20wake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scanForPeripherals(withServices:) is an OR filter, so [C005,C00A] matched the pod continuously via C005 (reminder always configured) — iOS treated it as already-discovered and coalesced the C00A re-discovery (~7min deep-idle latency observed). Scan for C00A ONLY: the pod doesn't match during normal operation, so a fault's C005->C00A flip is a genuinely NEW discovery — the event iOS wakes a suspended app for. Probe off to isolate the scan's true fault-wake latency. Compare fault pod-time (faultEventTimeSinceActivation) vs read (timeActive) to measure true fault->detection latency, as in the last run. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 4d37092..2862454 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -256,7 +256,7 @@ class BluetoothManager: NSObject { /// fired but was not detected until the app was reopened. So the alarm scan is best-effort and the /// probe bounds detection latency. Left as a knob for future measurement; keep OFF in normal use. static var heartbeatProbeSuppressed: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.heartbeatProbeSuppressed") as? Bool ?? false // probe ON: production config (probe + alarm scan coexisting) — confirm the scan still wakes fast on a deep-idle fault + UserDefaults.standard.object(forKey: "OmnipodKit.heartbeatProbeSuppressed") as? Bool ?? true // C00A-ONLY TEST: probe OFF so the C00A fresh-discovery scan wake is the only wake — measures its true deep-idle fault latency } /// Start delay (seconds) for the delayed-connect probe. Note the real wake lands at StartDelay + @@ -274,11 +274,14 @@ class BluetoothManager: NSObject { /// from a real [BEACON] capture before relying on these. /// - `C00A`: CONFIRMED fault 2nd-UUID (captured occlusion 0x14 — the pod's 2nd service UUID went /// C001(normal)→C005(alert)→C00A(fault)). Include it so a fault wakes the low-power scan. + /// EXPERIMENT (revert after): C00A-ONLY fault scan. Dropping C005 means the pod does NOT match the + /// scan during normal operation (it advertises C005), so iOS isn't tracking it as "discovered". When + /// a fault flips the 2nd UUID to C00A, the pod becomes a genuinely NEW discovery — the event iOS + /// wakes a suspended app for — which should give a much faster deep-idle fault wake than the OR + /// filter (which kept the peripheral perpetually seen via C005 and coalesced the C00A re-discovery). + /// Gives up C005-based connectionless ALERT detection (already unusable in deep idle anyway). static let alarmServiceUUIDs: [CBUUID] = [ - CBUUID(string: "C005"), CBUUID(string: "C00A"), - CBUUID(string: "CE1F923D-C539-48EA-7300-0A179F0CF102"), - CBUUID(string: "CE1F923D-C539-48EA-7300-0A179F0CF103"), ] /// Connect-request timestamps (by peripheral UUID) for measuring connect latency in didConnect. From 6aa96e9571f8380eb8bf3350dab28a9c2fddf639 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 8 Jul 2026 23:57:01 -0500 Subject: [PATCH 66/92] adopt C00A-only fault scan as production design (fast connectionless fault detection) Validated 2026-07-08: C00A-only alarm scan gives <1min proactive deep-idle fault detection (fresh discovery), vs ~7min with the [C005,C00A] OR filter (pod perpetually matched via C005 -> iOS coalesced the re-discovery). Production config: C00A-only scan + heartbeat probe (ON) + foreground keep-alive. Faults wake the scan directly and fast; alerts/loop-heartbeat handled by the probe (~5min) and keep-alive. Turn off beacon-capture; probe-suppress knob back to default OFF. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 29 ++++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 2862454..b8f41af 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -202,7 +202,7 @@ class BluetoothManager: NSObject { /// normal↔triggered-alert diff pins the alarm-code offsets + the stable background-filter UUID. /// Heavy (wildcard foreground scan) — field-test only; revert before merge. static var beaconCaptureEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.beaconCaptureEnabled") as? Bool ?? true // ALERT-ADVERT CAPTURE: wildcard scan + device-log full adverts to diff configured vs firing UUIDs (revert after) + UserDefaults.standard.object(forKey: "OmnipodKit.beaconCaptureEnabled") as? Bool ?? false } /// Prefix of the DASH alarm/beacon 128-bit service UUID (per RE spec §3). @@ -250,13 +250,13 @@ class BluetoothManager: NSObject { UserDefaults.standard.object(forKey: "OmnipodKit.delayedConnectProbeEnabled") as? Bool ?? false } - /// Debug knob (default OFF): suppress the delayed-connect heartbeat probe so the idle alarm scan - /// runs with NO pending StartDelay connect. An experiment (2026-07-08) showed the probe is NOT - /// starving the scan — it's the reliable background WAKE: with it suppressed, a deep-idle alert - /// fired but was not detected until the app was reopened. So the alarm scan is best-effort and the - /// probe bounds detection latency. Left as a knob for future measurement; keep OFF in normal use. + /// Debug knob (default OFF = probe ON): suppress the delayed-connect heartbeat probe so the idle + /// alarm scan runs with NO pending StartDelay connect. Experiments (2026-07-08): the probe is the + /// heartbeat + the deep-idle surfacing path for ALERTS (which the scan can't wake on — no UUID + /// change). FAULTS wake the C00A-only scan directly (<1min, fresh discovery). Both run in production. + /// Left as a knob for future measurement; keep OFF in normal use. static var heartbeatProbeSuppressed: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.heartbeatProbeSuppressed") as? Bool ?? true // C00A-ONLY TEST: probe OFF so the C00A fresh-discovery scan wake is the only wake — measures its true deep-idle fault latency + UserDefaults.standard.object(forKey: "OmnipodKit.heartbeatProbeSuppressed") as? Bool ?? false } /// Start delay (seconds) for the delayed-connect probe. Note the real wake lands at StartDelay + @@ -274,12 +274,15 @@ class BluetoothManager: NSObject { /// from a real [BEACON] capture before relying on these. /// - `C00A`: CONFIRMED fault 2nd-UUID (captured occlusion 0x14 — the pod's 2nd service UUID went /// C001(normal)→C005(alert)→C00A(fault)). Include it so a fault wakes the low-power scan. - /// EXPERIMENT (revert after): C00A-ONLY fault scan. Dropping C005 means the pod does NOT match the - /// scan during normal operation (it advertises C005), so iOS isn't tracking it as "discovered". When - /// a fault flips the 2nd UUID to C00A, the pod becomes a genuinely NEW discovery — the event iOS - /// wakes a suspended app for — which should give a much faster deep-idle fault wake than the OR - /// filter (which kept the peripheral perpetually seen via C005 and coalesced the C00A re-discovery). - /// Gives up C005-based connectionless ALERT detection (already unusable in deep idle anyway). + /// C00A-ONLY fault scan (the adopted design; validated 2026-07-08). scanForPeripherals(withServices:) + /// is an OR filter, so including C005 kept the pod perpetually matched (a reminder is always + /// configured → C005 always advertised) → iOS treated it as already-discovered and COALESCED the + /// C005→C00A re-discovery (~7min deep-idle fault latency measured). Filtering on C00A ONLY means the + /// pod does not match during normal operation, so a fault's C005→C00A flip is a genuinely NEW + /// discovery — the event iOS wakes a suspended app for — giving <1min proactive deep-idle fault + /// detection (measured, probe off). We forgo C005-based connectionless ALERT detection, which never + /// worked in deep idle anyway (mfg-only change, no UUID change to wake on); alerts are surfaced by + /// the heartbeat probe (~5min) and the foreground keep-alive connection. static let alarmServiceUUIDs: [CBUUID] = [ CBUUID(string: "C00A"), ] From bc8a11868aa972fbc67fb9ef01e14b35773ab3f3 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 9 Jul 2026 11:08:40 -0500 Subject: [PATCH 67/92] FAULT-TYPE CAPTURE (revert after): wildcard capture to see a non-occlusion fault's advert Run-out-of-insulin (empty reservoir) fault: does it advertise C00A like the occlusion (so the C00A-only scan catches ALL faults, type in the mfg FaultEventCode byte), or a DIFFERENT 2nd UUID (which the C00A scan would MISS)? The occlusion had b1=0x14=FaultEventCode -> if b1 tracks the fault code, other faults get other UUIDs = C0(b1/2). Disable the C00A-only alarm scan and enable the wildcard beacon capture so we log the full advert (all UUIDs + mfg) regardless of which UUID this fault uses. Probe stays on so Loop keeps delivering (to actually run the pod dry). --- OmnipodKit/Bluetooth/BluetoothManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index b8f41af..6f55321 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -202,7 +202,7 @@ class BluetoothManager: NSObject { /// normal↔triggered-alert diff pins the alarm-code offsets + the stable background-filter UUID. /// Heavy (wildcard foreground scan) — field-test only; revert before merge. static var beaconCaptureEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.beaconCaptureEnabled") as? Bool ?? false + UserDefaults.standard.object(forKey: "OmnipodKit.beaconCaptureEnabled") as? Bool ?? true // FAULT-TYPE CAPTURE: wildcard scan + full-advert [ADV] logging to see the empty-reservoir fault's UUIDs/mfg (revert after) } /// Prefix of the DASH alarm/beacon 128-bit service UUID (per RE spec §3). @@ -219,7 +219,7 @@ class BluetoothManager: NSObject { /// so we get a background fault wake but don't wake on every normal advert. Connect-on-demand /// (its own light helper scan) handles command connects. static var lowPowerMonitorEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.lowPowerMonitorEnabled") as? Bool ?? true + UserDefaults.standard.object(forKey: "OmnipodKit.lowPowerMonitorEnabled") as? Bool ?? false // FAULT-TYPE CAPTURE: off so beaconCapture wildcard scan runs — see the empty-reservoir fault's full advert UUIDs (revert after) } /// Field-test master switch for the IDLE scan (startScanning). ON = run the alarm-filtered From 5cdafaaf3115d46bb1fc2c66d716d84d29331fb1 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 9 Jul 2026 11:26:45 -0500 Subject: [PATCH 68/92] field advert logging: device-log distinct pod adverts in production (keep) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advertisement data (service UUIDs + mfg) is only available in didDiscover, never after connecting. In connect-on-demand that fires during each command connect's fresh-discovery scan (and on a C00A fault-scan wake), so we can log it there. Ungate the deduped pod-advert device-log from beaconCapture to isPodFrame so it runs in production (under advertisementMonitorEnabled) — real-world Issue Reports now capture what the pod advertises, giving us field data to decode more fault/alert states. Deduped by svcUUIDs|mfg so it logs a transition once. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 6f55321..f49017e 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -1021,9 +1021,12 @@ extension BluetoothManager: CBCentralManagerDelegate { log.default("%{public}@ %{public}@ dt=%{public}@s rssi=%{public}@ state=%{public}@ connectable=%{public}@ name=%{public}@ svcUUIDs=[%{public}@] mfg=%{public}@ svcData=%{public}@", tag, peripheral.identifier.uuidString, dt, RSSI, String(describing: peripheral.state.rawValue), String(describing: connectable), name.isEmpty ? "-" : name, svcUUIDs.isEmpty ? "-" : svcUUIDs, mfg, svcData) - // FAULT-CAPTURE: record each DISTINCT advert to the device log (so a fault advert lands in the - // Issue Report). Deduped by svcUUIDs|mfg so we log a change once, not every ~1Hz frame. - if BluetoothManager.beaconCaptureEnabled { + // Field advert logging (kept in production): record each DISTINCT pod advert to the device log + // so real-world Issue Reports capture what the pod advertises — the raw material for decoding + // more fault/alert states. Deduped by svcUUIDs|mfg (the advert is stable between state changes, + // so this logs a transition once, not every ~1Hz frame). Fires whenever we discover the pod — + // i.e. during each command connect's fresh-discovery scan and on a C00A fault-scan wake. + if isPodFrame { let advKey = "\(svcUUIDs)|\(mfg)|conn=\(String(describing: connectable))" if lastLoggedAdvKey[peripheral.identifier.uuidString] != advKey { lastLoggedAdvKey[peripheral.identifier.uuidString] = advKey From 15927810bf2acdf5f3009dccc546277f13ee0f44 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 9 Jul 2026 12:57:26 -0500 Subject: [PATCH 69/92] fix: deactivating a faulted pod could deadlock forever (bound store() wait) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: a cross-thread deadlock over BlePodComms.podStateLock. A pod session runs on the command queue HOLDING podStateLock (bleRunSession) and, via the post-connect status fetch, reaches store(doses:in:) which blocks on an untimed semaphore.wait() until Loop's DoseStore delegate confirms — but that completion is dispatched to .main. Meanwhile the deactivation UI's forgetPod -> handleDiscardedPodDosing runs on .main and blocks acquiring podStateLock. Circular wait -> permanent silent hang (observed ~10 min until force-kill; the pod itself deactivated fine). Newly reachable for FAULTED pods via the recent handlePodUpdatesAsNeeded change (flush when 'status != nil || isFaulted'): a faulted pod's getStatus returns nil, so the blocking store() used to be skipped; now it runs while podStateLock is held during the deactivate/discard race. Fix: bound the wait at 10s. On timeout, return false so dosesForStorage retains the doses for a later flush (idempotent — DoseStore dedupes by syncIdentifier); returning releases podStateLock and breaks the deadlock. Guarantees liveness for every store(doses:in:) caller. Legit stores complete in well under a second. --- OmnipodKit/PumpManager/OmniPumpManager.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index 7a73c6c..01ec00c 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -3190,7 +3190,7 @@ extension OmniPumpManager: PumpManager { func store(doses: [UnfinalizedDose], in session: PodCommsSession) -> Bool { session.assertOnSessionQueue() - // We block the session until the data's confirmed stored by the delegate + // We block the session until the data's confirmed stored by the delegate. let semaphore = DispatchSemaphore(value: 0) var success = false @@ -3199,7 +3199,18 @@ extension OmniPumpManager: PumpManager { semaphore.signal() } - semaphore.wait() + // Bounded wait to guarantee liveness. The completion is dispatched to the delegate queue (Loop's + // is .main), while this runs on the pod command queue HOLDING podStateLock (BlePodComms.bleRunSession). + // If the main thread is itself blocked acquiring podStateLock — e.g. a concurrent forgetPod / + // handleDiscardedPodDosing during pod deactivation — an untimed wait() deadlocks the command queue + // forever (observed: deactivating a faulted pod hung ~10 min until force-kill). On timeout, bail and + // return false so dosesForStorage RETAINS the doses for a later flush (re-storing is idempotent — + // DoseStore dedupes by syncIdentifier). Returning unwinds the session and releases podStateLock, + // breaking the deadlock. Legitimate stores complete in well under a second. + if semaphore.wait(timeout: .now() + .seconds(10)) == .timedOut { + self.log.error("store(doses:) timed out waiting for delegate confirmation — retaining %d dose(s) for retry (avoids podStateLock deadlock)", doses.count) + return false + } if success { setState { (state) in From 2eef2022a9a9040244d315dced091116ddb93ceb Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 9 Jul 2026 13:23:24 -0500 Subject: [PATCH 70/92] cleanup 1/n: connect-delay probe driven purely by Loop's heartbeat request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify delayedConnectProbeActive to just heartbeatEnabled (set via PumpManager.setMustProvideBLEHeartbeat) — remove the heartbeatProbeSuppressed and delayedConnectProbeEnabled experiment knobs that gated it, so nothing can stop the StartDelay heartbeat probe from running when Loop asks for a heartbeat. Revert the fault-type-capture experiment defaults to production (beaconCapture=false, lowPowerMonitor=true: C00A alarm scan + keep-alive on). --- OmnipodKit/Bluetooth/BluetoothManager.swift | 31 +++++---------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index f49017e..a1bc32c 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -202,7 +202,7 @@ class BluetoothManager: NSObject { /// normal↔triggered-alert diff pins the alarm-code offsets + the stable background-filter UUID. /// Heavy (wildcard foreground scan) — field-test only; revert before merge. static var beaconCaptureEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.beaconCaptureEnabled") as? Bool ?? true // FAULT-TYPE CAPTURE: wildcard scan + full-advert [ADV] logging to see the empty-reservoir fault's UUIDs/mfg (revert after) + UserDefaults.standard.object(forKey: "OmnipodKit.beaconCaptureEnabled") as? Bool ?? false } /// Prefix of the DASH alarm/beacon 128-bit service UUID (per RE spec §3). @@ -219,7 +219,7 @@ class BluetoothManager: NSObject { /// so we get a background fault wake but don't wake on every normal advert. Connect-on-demand /// (its own light helper scan) handles command connects. static var lowPowerMonitorEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.lowPowerMonitorEnabled") as? Bool ?? false // FAULT-TYPE CAPTURE: off so beaconCapture wildcard scan runs — see the empty-reservoir fault's full advert UUIDs (revert after) + UserDefaults.standard.object(forKey: "OmnipodKit.lowPowerMonitorEnabled") as? Bool ?? true } /// Field-test master switch for the IDLE scan (startScanning). ON = run the alarm-filtered @@ -241,24 +241,6 @@ class BluetoothManager: NSObject { UserDefaults.standard.object(forKey: "OmnipodKit.suppressCommandsEnabled") as? Bool ?? false } - /// Experiment: after each disconnect, issue a connect with CBConnectPeripheralOptionStartDelayKey - /// so iOS holds the request pending for N seconds, then completes it (the pod advertises ~1Hz, so - /// it connects ~immediately once the delay elapses). Testing whether a delayed connect gives a - /// timed background WAKE — the periodic wake the scan path can't (stable payload coalesces). Loop: - /// discover -> delayed-connect(N) -> didConnect (measure) -> brief hold -> disconnect -> repeat. - static var delayedConnectProbeEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.delayedConnectProbeEnabled") as? Bool ?? false - } - - /// Debug knob (default OFF = probe ON): suppress the delayed-connect heartbeat probe so the idle - /// alarm scan runs with NO pending StartDelay connect. Experiments (2026-07-08): the probe is the - /// heartbeat + the deep-idle surfacing path for ALERTS (which the scan can't wake on — no UUID - /// change). FAULTS wake the C00A-only scan directly (<1min, fresh discovery). Both run in production. - /// Left as a knob for future measurement; keep OFF in normal use. - static var heartbeatProbeSuppressed: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.heartbeatProbeSuppressed") as? Bool ?? false - } - /// Start delay (seconds) for the delayed-connect probe. Note the real wake lands at StartDelay + /// an iOS reacquisition tail (~40s observed), so 300 → wake ~340s. static var delayedConnectProbeSeconds: Int { @@ -327,11 +309,12 @@ class BluetoothManager: NSObject { /// demand. managerQueue-isolated. private var heartbeatEnabled = false - /// The delayed-connect loop is active when the host requests a heartbeat OR the manual test flag - /// is set. Isolated to managerQueue (all probe call sites run there). + /// The delayed-connect (StartDelay) heartbeat probe runs exactly when Loop asks the pump to provide + /// the BLE heartbeat — i.e. `heartbeatEnabled`, set via PumpManager.setMustProvideBLEHeartbeat. No + /// other gate: whenever the host needs a pump-provided heartbeat, the connect-delay probe is active. + /// Isolated to managerQueue (all probe call sites run there). private var delayedConnectProbeActive: Bool { - if BluetoothManager.heartbeatProbeSuppressed { return false } // EXPERIMENT: scan-only, no probe - return heartbeatEnabled || BluetoothManager.delayedConnectProbeEnabled + heartbeatEnabled } /// Enable/disable the pump-provided heartbeat (delayed-connect loop). Driven by From 58745cb91adf953823235e4bcc160ee0bc039df3 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 9 Jul 2026 13:31:11 -0500 Subject: [PATCH 71/92] cleanup 2/n: remove field-test scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove dead field-test-only code, preserving production behavior (connect-on-demand, C00A alarm/fault scan, foreground keep-alive, StartDelay heartbeat probe, connectionless fault detection, field advert [ADV] logging): - beaconCaptureEnabled (§5 wildcard-capture) + all usages: appIsForeground gate, keep-alive guard, the wildcard startScanning branch, resumeScan/didDiscover gates. - Speculative CE1F923D beacon support: beaconUUIDPrefix, isBeaconFrame, [BEACON] tag. - suppressCommandsEnabled measurement mode (+ BlePodComms bleRunSession block, didConnect term). - Stray dead 'CBConnectPeripheralOptionStartDelayKey' expression in startScanning. - DEBUG Trigger Test Alert: triggerTestAlert() in OmniPumpManager + DiagnosticCommands protocol + PodDiagnosticsView button. Build verified (LoopWorkspace, device); no dangling references. --- OmnipodKit/Bluetooth/BlePodComms.swift | 9 --- OmnipodKit/Bluetooth/BluetoothManager.swift | 67 +++---------------- OmnipodKit/PumpManager/OmniPumpManager.swift | 34 ---------- .../Views/PodDiagnosticsView.swift | 10 --- 4 files changed, 11 insertions(+), 109 deletions(-) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index b9028b9..d244245 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -696,15 +696,6 @@ class BlePodComms: PodComms { func bleRunSession(withName name: String, _ block: @escaping (_ result: SessionRunResult) -> Void) { - // Measurement mode: leave the pod fully idle-disconnected so the wildcard scan runs - // uninterrupted (no connect churn stopping it). Needed for a clean advert-cadence / CE1F923D - // capture. Field-test only — this stops ALL pod commands. Revert before merge. - if BluetoothManager.suppressCommandsEnabled { - log.default("[suppressCommands] skipping session '%{public}@' — pod left idle for measurement", name) - block(.failure(PodCommsError.podNotConnected)) - return - } - // In connect-on-demand mode the pod is normally disconnected, and self.manager (set only in // omnipodPeripheralDidConnect / on restore) is nil on a fresh launch — nothing has connected // yet. Adopt the pod's PeripheralManager from the device list (it exists while disconnected) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index a1bc32c..caf417d 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -196,18 +196,6 @@ class BluetoothManager: NSObject { UserDefaults.standard.object(forKey: "OmnipodKit.connectOnDemandEnabled") as? Bool ?? true } - /// §5 beacon-capture: scan withServices:nil (foreground, allowDuplicates) so we catch the pod's - /// alarm/beacon advertisement even if it advertises a service UUID we don't yet filter on (e.g. - /// the CE1F923D-… alarm beacon). Logs full raw fields for any pod-adjacent or CE1F923D frame so a - /// normal↔triggered-alert diff pins the alarm-code offsets + the stable background-filter UUID. - /// Heavy (wildcard foreground scan) — field-test only; revert before merge. - static var beaconCaptureEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.beaconCaptureEnabled") as? Bool ?? false - } - - /// Prefix of the DASH alarm/beacon 128-bit service UUID (per RE spec §3). - static let beaconUUIDPrefix = "CE1F923D-C539-48EA-7300-0A" - /// Low-power fault-watch (option 3): scan filtered on the DASH ALARM service UUID(s) with /// allowDuplicates OFF, so iOS only wakes us when the pod enters an alarm state (2nd service /// UUID flips to an alarm value) — zero wakes during normal operation, and it survives into the @@ -234,13 +222,6 @@ class BluetoothManager: NSObject { UserDefaults.standard.object(forKey: "OmnipodKit.scanningEnabled") as? Bool ?? true } - /// Measurement mode (field-test only): skip ALL pod commands so the pod is left idle-disconnected - /// and the wildcard scan runs uninterrupted — a clean window to measure the advert cadence and - /// see whether a CE1F923D beacon ever appears, without connect churn stopping the scan. - static var suppressCommandsEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.suppressCommandsEnabled") as? Bool ?? false - } - /// Start delay (seconds) for the delayed-connect probe. Note the real wake lands at StartDelay + /// an iOS reacquisition tail (~40s observed), so 300 → wake ~340s. static var delayedConnectProbeSeconds: Int { @@ -250,10 +231,6 @@ class BluetoothManager: NSObject { /// Candidate DASH alarm-state service UUIDs to filter on in low-power mode. /// - `C005`: CONFIRMED 16-bit alarm 2nd-UUID on this pod (expiration reminder). Extend as more /// alert/alarm types are captured. - /// - The 128-bit AS/AST are the RE binary model `CE1F923D-C539-48EA-7300-0A` with - /// deviceId GUESSED = pod address 179F0CF1 (TT 02=AS, 03=AST). UNCONFIRMED — no CE1F923D frame - /// has appeared in field capture; harmless if wrong (just won't match). Fix deviceId/byte-order - /// from a real [BEACON] capture before relying on these. /// - `C00A`: CONFIRMED fault 2nd-UUID (captured occlusion 0x14 — the pod's 2nd service UUID went /// C001(normal)→C005(alert)→C00A(fault)). Include it so a fault wakes the low-power scan. /// C00A-ONLY fault scan (the adopted design; validated 2026-07-08). scanForPeripherals(withServices:) @@ -293,9 +270,7 @@ class BluetoothManager: NSObject { /// live and in-app commands are instant. On background we disconnect and resume the heartbeat probe. private var isAppForeground = false /// Cross-queue read for PeripheralManager's idle-disconnect (benign bool race, like everForeground). - /// In fault-capture mode keep-alive is off so the pod stays disconnected + advertising for the - /// wildcard scan to record a fault advert. - var appIsForeground: Bool { isAppForeground && !BluetoothManager.beaconCaptureEnabled } + var appIsForeground: Bool { isAppForeground } /// True once this PROCESS has ever been foregrounded. A [delayedConnect] with everFg=false means /// iOS ran this process entirely in the background — proof of a background wake/relaunch the user @@ -688,7 +663,7 @@ class BluetoothManager: NSObject { private func enterForeground() { dispatchPrecondition(condition: .onQueue(managerQueue)) isAppForeground = true - if !BluetoothManager.beaconCaptureEnabled, let peripheral = keepAlivePeripheral, peripheral.state == .disconnected { + if let peripheral = keepAlivePeripheral, peripheral.state == .disconnected { log.default("[connectOnDemand] foreground — pre-connecting for keep-alive") connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] foreground — pre-connecting for keep-alive") beginCommandConnect(peripheral) @@ -747,24 +722,16 @@ class BluetoothManager: NSObject { // allowDuplicates. Takes precedence over the monitor/beacon scans. services = BluetoothManager.alarmServiceUUIDs options = [:] - } else if BluetoothManager.beaconCaptureEnabled { - // §5: scan withServices:nil (wildcard) + allowDuplicates so we catch a beacon advertising - // a UUID we don't yet filter on. Foreground-only (the point of §5 is to find the filter UUID). - services = nil - options = [CBCentralManagerScanOptionAllowDuplicatesKey: true] } else { // Monitor mode: filter on the pod's main service; allowDuplicates to see the advert cadence. services = [serviceUUID] options = BluetoothManager.advertisementMonitorEnabled ? [CBCentralManagerScanOptionAllowDuplicatesKey: true] : [:] } - log.default("Start scanning (filter=%{public}@, lowPowerMonitor=%{public}@, beaconCapture=%{public}@, allowDuplicates=%{public}@)", + log.default("Start scanning (filter=%{public}@, lowPowerMonitor=%{public}@, allowDuplicates=%{public}@)", services == nil ? "nil (wildcard)" : services!.map { $0.uuidString }.joined(separator: ","), String(describing: BluetoothManager.lowPowerMonitorEnabled), - String(describing: BluetoothManager.beaconCaptureEnabled), String(describing: options[CBCentralManagerScanOptionAllowDuplicatesKey] != nil)) manager.scanForPeripherals(withServices: services, options: options) - - CBConnectPeripheralOptionStartDelayKey } private func stopScanning() { @@ -776,7 +743,7 @@ class BluetoothManager: NSObject { /// during the connect because an active allowDuplicates scan starves connection completion). /// Only when nothing is connected, so we never scan while a command is using the link. private func resumeScanIfNeeded() { - guard BluetoothManager.advertisementMonitorEnabled || BluetoothManager.beaconCaptureEnabled || BluetoothManager.lowPowerMonitorEnabled else { return } + guard BluetoothManager.advertisementMonitorEnabled || BluetoothManager.lowPowerMonitorEnabled else { return } guard !alarmScanSuppressed else { return } // an alert is active — stay quiet (re-wake quieting) guard manager?.state == .poweredOn, !manager.isScanning else { return } guard !devices.contains(where: { $0.manager.peripheral.state == .connected || $0.manager.peripheral.state == .connecting }) else { return } @@ -972,10 +939,8 @@ extension BluetoothManager: CBCentralManagerDelegate { private func surfacePodConditionAndQuiet(alertSet: AlertSet) { dispatchPrecondition(condition: .onQueue(managerQueue)) connectionDelegate?.omnipodDidDetectAlert(slots: alertSet) - if !BluetoothManager.beaconCaptureEnabled { // capture mode keeps the wildcard scan recording - alarmScanSuppressed = true - if manager.isScanning { manager.stopScan() } - } + alarmScanSuppressed = true + if manager.isScanning { manager.stopScan() } } func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { @@ -986,23 +951,20 @@ extension BluetoothManager: CBCentralManagerDelegate { // Full advertisement dump for pod-adjacent frames — the raw material for §5 (normal↔alarm // diff) and the "faults via advertisement" model. Captures every field, every time. let advSvcUUIDs = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]) ?? [] - let isBeaconFrame = advSvcUUIDs.contains { $0.uuidString.uppercased().hasPrefix(BluetoothManager.beaconUUIDPrefix) } let isPodFrame = autoConnectIDs.contains(peripheral.identifier.uuidString) || PodAdvertisement(advertisementData, podType: podType) != nil - if (BluetoothManager.advertisementMonitorEnabled || BluetoothManager.beaconCaptureEnabled), isPodFrame || isBeaconFrame { + if BluetoothManager.advertisementMonitorEnabled, isPodFrame { let svcUUIDs = advSvcUUIDs.map { $0.uuidString }.joined(separator: ",") let mfg = (advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data)?.hexadecimalString ?? "-" let svcData = (advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data])? .map { "\($0.key.uuidString):\($0.value.hexadecimalString)" }.joined(separator: ",") ?? "-" let connectable = advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "-" - // Tag beacon frames distinctly so the §5 diff is trivial to grep. - let tag = isBeaconFrame ? "[BEACON]" : "[ADV]" // Inter-frame delta = the advertising cadence (RE's DS-beacon-rate question). let now = Date() let dt = lastAdvSeen[peripheral.identifier.uuidString].map { String(format: "%.2f", now.timeIntervalSince($0)) } ?? "-" lastAdvSeen[peripheral.identifier.uuidString] = now - log.default("%{public}@ %{public}@ dt=%{public}@s rssi=%{public}@ state=%{public}@ connectable=%{public}@ name=%{public}@ svcUUIDs=[%{public}@] mfg=%{public}@ svcData=%{public}@", - tag, peripheral.identifier.uuidString, dt, RSSI, String(describing: peripheral.state.rawValue), + log.default("[ADV] %{public}@ dt=%{public}@s rssi=%{public}@ state=%{public}@ connectable=%{public}@ name=%{public}@ svcUUIDs=[%{public}@] mfg=%{public}@ svcData=%{public}@", + peripheral.identifier.uuidString, dt, RSSI, String(describing: peripheral.state.rawValue), String(describing: connectable), name.isEmpty ? "-" : name, svcUUIDs.isEmpty ? "-" : svcUUIDs, mfg, svcData) // Field advert logging (kept in production): record each DISTINCT pod advert to the device log // so real-world Issue Reports capture what the pod advertises — the raw material for decoding @@ -1013,12 +975,11 @@ extension BluetoothManager: CBCentralManagerDelegate { let advKey = "\(svcUUIDs)|\(mfg)|conn=\(String(describing: connectable))" if lastLoggedAdvKey[peripheral.identifier.uuidString] != advKey { lastLoggedAdvKey[peripheral.identifier.uuidString] = advKey - connectionDelegate?.omnipodLogDeviceEvent("\(tag) svcUUIDs=[\(svcUUIDs.isEmpty ? "-" : svcUUIDs)] mfg=\(mfg) connectable=\(String(describing: connectable)) svcData=\(svcData)") + connectionDelegate?.omnipodLogDeviceEvent("[ADV] svcUUIDs=[\(svcUUIDs.isEmpty ? "-" : svcUUIDs)] mfg=\(mfg) connectable=\(String(describing: connectable)) svcData=\(svcData)") } } } else if let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data, - BluetoothManager.advertisementMonitorEnabled, !BluetoothManager.beaconCaptureEnabled { - // Suppressed in beacon-capture (wildcard) mode — this fired for every nearby BLE device. + BluetoothManager.advertisementMonitorEnabled { log.default("[SCAN] ManufacturerData: %{public}@ (%{public}d bytes)", mfgData.hexadecimalString, mfgData.count) } @@ -1089,16 +1050,10 @@ extension BluetoothManager: CBCentralManagerDelegate { // Delayed-connect probe: report the delay, then disconnect after a brief hold so the loop // re-arms (didDisconnect issues the next probe). Skip the normal session proxy — timing only. - // - delayedProbeInFlight: a connect WE issued as a probe. - // - suppressCommands + active: test mode has no real command connects, so any connect - // (incl. a restored one after relaunch) is a probe and must re-arm. - // In normal (command) mode we must NOT hijack a real command connect, so only genuine - // in-flight probes qualify. // A genuine heartbeat-probe wake: the StartDelay connect WE issued completed, and no command // is using the link. (A command connect sets commandConnectInFlight and clears delayedProbeInFlight, // so it never lands here — that was the old hijack that cancelled real commands after 2s.) let treatAsProbe = (delayedProbeInFlight && !commandConnectInFlight) - || (delayedConnectProbeActive && BluetoothManager.suppressCommandsEnabled) if treatAsProbe { let measured = delayedProbeIssuedAt.map { String(format: "%.1f", Date().timeIntervalSince($0)) } ?? "?(restored)" let pid = ProcessInfo.processInfo.processIdentifier diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index 01ec00c..538e308 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -1855,40 +1855,6 @@ extension OmniPumpManager { #endif } - /// DEBUG (field test): program a real, non-fault pod alert to fire ~60s from now, so we can - /// capture the pod's alarm/beacon advertisement (§5) non-destructively. Uses the expiration- - /// reminder slot with a near-future absAlertTime — the pod beeps and (per the RE spec) should - /// flip its advertisement to the AS/beacon state. It's an alert, not a fault: acknowledge it - /// away and the pod is fine. NOTE: this overwrites the pod's expiration reminder — reset that in - /// settings after testing. - func triggerTestAlert() async throws { - guard self.hasActivePod else { - throw OmniPumpManagerError.noPodPaired - } - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - self.runSession(withName: "Trigger Test Alert") { (result) in - switch result { - case .success(let session): - self.handleSilencePodEnd(session: session) - let podTime = self.podTime - let alertPodTime = podTime + TimeInterval(seconds: 60) // fire ~60s from now (mirrors updateExpirationReminder math) - let testAlert = PodAlert.expirationReminder(offset: podTime, absAlertTime: alertPodTime, silent: false) - do { - let beepBlock = self.beepMessageBlock(beepType: .beep) - let _ = try session.configureAlerts([testAlert], beepBlock: beepBlock) - self.log.default("[testAlert] scheduled expirationReminder to fire in ~60s (podTime=%{public}@, alertPodTime=%{public}@)", podTime.timeIntervalStr, alertPodTime.timeIntervalStr) - self.logDeviceCommunication("[testAlert] scheduled expirationReminder to fire in ~60s (alertPodTime=\(alertPodTime.timeIntervalStr))", type: .connection) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - func playTestBeeps() async throws { guard self.hasActivePod else { throw OmniPumpManagerError.noPodPaired diff --git a/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift b/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift index 8cabb91..69b5089 100644 --- a/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift @@ -14,7 +14,6 @@ import HealthKit protocol DiagnosticCommands { - func triggerTestAlert() async throws func playTestBeeps() async throws func readPulseLog() async throws -> String func readPulseLogPlus() async throws -> String @@ -48,15 +47,6 @@ struct PodDiagnosticsView: View { } .disabled(!podOk) - // DEBUG (field test): fire a real non-fault alert ~60s out to capture the beacon (§5). - Button(action: { - Task { try? await diagnosticCommands.triggerTestAlert() } - }) { - Text("Trigger Test Alert (debug, fires in ~60s)") - .foregroundColor(Color.primary) - } - .disabled(!podOk) - NavigationLink(destination: ReadPodInfoView( title: LocalizedString("Read Pulse Log", comment: "Text for read pulse log title"), actionString: LocalizedString("Reading Pulse Log...", comment: "Text for read pulse log action"), From 1223cccbbfe97a1ec4bb3fc73fb4a592595a3e0b Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 9 Jul 2026 13:33:02 -0500 Subject: [PATCH 72/92] cleanup 3/n: update flag docs to the shipped design (comments only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop 'field-test' framing and fix the now-wrong note claiming fault-listening and the heartbeat don't coexist — they do (C00A scan + StartDelay probe both run while idle). Update C005->C00A fault-scan description on lowPowerMonitorEnabled. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 41 +++++++++------------ 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index caf417d..449e8e7 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -182,42 +182,35 @@ class BluetoothManager: NSObject { /// N distinct INITs with no matching DEINITs = leaked centrals (the suspected pairing-bug root). let instanceID = String(UUID().uuidString.prefix(8)) - /// Field-test flag: keep scanning continuously (allowDuplicates) and log every pod advertisement, - /// to test the "stay scanning, connect on demand, faults signalled via advertisement" model. + /// Log each distinct pod advertisement ([ADV] → device log) so field Issue Reports capture what the + /// pod broadcasts — raw material for decoding more fault/alert states — and enable allowDuplicates on + /// the fallback monitor scan. Kept in production. static var advertisementMonitorEnabled: Bool { UserDefaults.standard.object(forKey: "OmnipodKit.advertisementMonitorEnabled") as? Bool ?? true } - /// Field-test flag: "normally disconnected" model. When on, the auto-reconnect machinery is - /// suppressed (the pod is NOT held connected); PeripheralManager connects on demand for each - /// session and disconnects when idle, and we scan (advertisementMonitor) while disconnected. - /// This changes how Loop stays in touch with the pump — every command pays a connect first. + /// The shipped "normally disconnected" model: the pod is NOT held connected; PeripheralManager + /// connects on demand for each session and disconnects when idle, and we alarm-scan while + /// disconnected. Every command pays a (fast fresh-discovery) connect first. static var connectOnDemandEnabled: Bool { UserDefaults.standard.object(forKey: "OmnipodKit.connectOnDemandEnabled") as? Bool ?? true } - /// Low-power fault-watch (option 3): scan filtered on the DASH ALARM service UUID(s) with - /// allowDuplicates OFF, so iOS only wakes us when the pod enters an alarm state (2nd service - /// UUID flips to an alarm value) — zero wakes during normal operation, and it survives into the - /// background via State Preservation/Restoration. Takes precedence over the monitor/beacon scans. - /// Trade-off: only catches the enumerated alarm UUIDs below (currently just the one confirmed - /// alert value); the clear transition isn't caught here (confirm on the next connect). See - /// DASH_BEACON_FINDINGS.md. Add more alarm UUID values as they're discovered. - /// Default ON for connect-on-demand mode: while idle, subscribe only for alarm adverts (`[C005]`), - /// so we get a background fault wake but don't wake on every normal advert. Connect-on-demand - /// (its own light helper scan) handles command connects. + /// Low-power fault-watch: the idle scan filters on the DASH FAULT service UUID(s) — `alarmServiceUUIDs` + /// = [C00A] — with allowDuplicates OFF, so iOS wakes us only when the pod's 2nd service UUID flips to + /// the fault value (C005→C00A). C00A isn't advertised in normal operation, so a fault is a fresh + /// discovery → a fast (<1 min) background wake that survives suspension via State Restoration; zero + /// wakes otherwise. Alerts (no service-UUID change) are NOT caught here — the heartbeat probe surfaces + /// those. Coexists with the StartDelay heartbeat probe. See DASH_BEACON_FINDINGS.md. static var lowPowerMonitorEnabled: Bool { UserDefaults.standard.object(forKey: "OmnipodKit.lowPowerMonitorEnabled") as? Bool ?? true } - /// Field-test master switch for the IDLE scan (startScanning). ON = run the alarm-filtered - /// low-power scan while disconnected — the unsolicited fault listener: iOS wakes us only on an - /// alert advertisement (`C005`), so `detectPodAlertStatus` catches faults connectionlessly (zero - /// wakes in normal operation). OFF = no idle scan (pure scan-free connect measurement). - /// NOTE: command connects use fresh-discovery (a direct scanForPeripherals) either way; this only - /// governs the idle listener. NOTE: while a heartbeat StartDelay probe is active it stops this scan - /// to avoid starving the probe connect — so fault-listening + heartbeat don't yet fully coexist - /// (test the listener with a BLE CGM present, i.e. heartbeat off). + /// Master switch for the IDLE scan (startScanning). ON = run the C00A fault listener while + /// disconnected (connectionless fault detection). OFF = no idle scan. Command connects use + /// fresh-discovery either way; this only governs the idle listener. The idle scan COEXISTS with the + /// StartDelay heartbeat probe — both run while idle: the probe provides the periodic heartbeat/alert + /// wake, the scan provides fast fault wakes. static var scanningEnabled: Bool { UserDefaults.standard.object(forKey: "OmnipodKit.scanningEnabled") as? Bool ?? true } From 61a837637c6f5b51237945e234e97a74882d0f87 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 9 Jul 2026 13:36:08 -0500 Subject: [PATCH 73/92] cleanup 4/n: drop init stack-trace dump; ship unsolicited-listener diagnostic OFF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the 12-frame Thread.callStackSymbols dump from BluetoothManager.init (debug scaffolding from the pairing-leak investigation; now just noise). - unsolicitedFaultListenerEnabled defaults OFF (was field-test ON): it's a passive connected-mode diagnostic that logs pod-initiated frames but does not route alerts or mutate session state — production fault detection is the C00A advert scan. Kept as an opt-in UserDefaults diagnostic. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 4 +--- OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift | 9 +++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 449e8e7..04c2c33 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -358,9 +358,7 @@ class BluetoothManager: NSObject { self.podType = podType super.init() - log.default("BluetoothManager #%{public}@ INIT (podType=%{public}@). Created from:\n%{public}@", - instanceID, String(describing: podType), - Thread.callStackSymbols.dropFirst().prefix(12).joined(separator: "\n")) + log.default("BluetoothManager #%{public}@ INIT (podType=%{public}@)", instanceID, String(describing: podType)) managerQueue.sync { self.manager = CBCentralManager(delegate: self, queue: managerQueue, options: [CBCentralManagerOptionRestoreIdentifierKey: "com.OmnipodKit"]) diff --git a/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift b/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift index c06a2da..cd42cc1 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift @@ -372,11 +372,12 @@ extension PeripheralManagerError { // and to capture the nonce-sequence behavior stage 2 needs. extension PeripheralManager { - /// FIELD TEST BUILD: defaults ON so the listener runs without UI to set the flag. - /// REVERT this commit before merge — stage 1 must ship OFF by default. The UserDefaults - /// key still overrides (set it to false to disable on-device). + /// Opt-in diagnostic (default OFF): a passive listener that logs pod-initiated (unsolicited) frames + /// pushed over an active connection. It does NOT route alerts or mutate session sequence state — + /// production fault detection is the connectionless C00A advertisement scan, not this. Ships off; + /// enable via the UserDefaults key for BLE protocol diagnostics. static var unsolicitedFaultListenerEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.unsolicitedFaultListenerEnabled") as? Bool ?? true + UserDefaults.standard.object(forKey: "OmnipodKit.unsolicitedFaultListenerEnabled") as? Bool ?? false } /// Called from the cmd/data value-update macros (BLE callback thread) AFTER the raw From 5d206d03ce572ed0bfb14fdf7cbaaa4bf195c36e Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 9 Jul 2026 17:01:32 -0500 Subject: [PATCH 74/92] cleanup 5/n: sweep leftover experiment/prototype comments to the shipped design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment-only. Drop §5 / FAULT-CAPTURE / PROTOTYPE / stage-1/2 / 'experiment' framing across BluetoothManager, BlePodComms, PeripheralManager(+OmnipodKit); reframe the opt-in unsolicited-listener diagnostic accordingly and correct the inaccurate 'does not mutate session state' note (it advances the nonce to stay in sync; it just doesn't route alerts). --- OmnipodKit/Bluetooth/BlePodComms.swift | 16 ++++++------- OmnipodKit/Bluetooth/BluetoothManager.swift | 24 +++++++++---------- .../PeripheralManager+OmnipodKit.swift | 19 +++++++-------- OmnipodKit/Bluetooth/PeripheralManager.swift | 6 ++--- 4 files changed, 32 insertions(+), 33 deletions(-) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index d244245..f2e81e8 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -352,7 +352,7 @@ class BlePodComms: PodComms { // The podState's bleMessageTransportState will be updated when the above defer block is executed. } - // MARK: - PROTOTYPE: periodic-status registration (arms the pod to push) + // MARK: - Periodic-status registration (arms the pod to push) /// BEST-GUESS, UNCONFIRMED envelope. Registers a periodic status push so the pod ORIGINATES /// status-response frames on a schedule (caught by the unsolicited listener), with fault/alert @@ -872,7 +872,7 @@ extension BlePodComms: PodCommsSessionDelegate { } } -// MARK: - PROTOTYPE: unsolicited (pod-initiated) fault decrypt + logging — Stage 1 +// MARK: - Unsolicited (pod-initiated) fault decrypt + logging (opt-in diagnostic) extension BlePodComms { func peripheralManager(_ manager: PeripheralManager, didReceiveUnsolicitedMessagePacket packet: MessagePacket) { podStateLock.lock() @@ -891,7 +891,7 @@ extension BlePodComms { // A normal response decrypts at nonceSeq+1 (readAndAckResponse increments nonceSeq // before decrypt). An unsolicited message may land at a different offset; the offset - // that decrypts is the key datum stage 2 needs to advance live state correctly. + // that decrypts is the key datum needed to advance live state correctly. let base = mts.nonceSeq for delta in [1, 0, 2, 3, -1] { let seq = base + delta @@ -911,9 +911,9 @@ extension BlePodComms { } else { log.default("[unsolicited] payload did not parse as a pod Message (may be an AID/text frame or partial)") } - // STAGE 2a: commit the nonce advance so the NEXT command stays in sync — the pod - // advanced its nonce for this push. Data-driven: use the offset that decrypted - // (expected +1). Runs on the serial sessionQueue, so no command overlaps this. + // Commit the nonce advance so the NEXT command stays in sync — the pod advanced its + // nonce for this push. Data-driven: use the offset that decrypted (expected +1). + // Runs on the serial sessionQueue, so no command overlaps this. podStateLock.lock() if var committed = podState?.bleMessageTransportState { let before = committed.nonceSeq @@ -922,8 +922,8 @@ extension BlePodComms { log.default("[unsolicited] committed nonceSeq %{public}d -> %{public}d (delta=%{public}d)", before, seq, delta) } podStateLock.unlock() - // STAGE 2b (TODO once decrypt is confirmed in the field): parse `decrypted` as a - // status/DetailedStatus response and route a fault via notifyPodFault. + // (Diagnostic only — production fault detection is the connectionless C00A advert scan, + // so we don't parse `decrypted` / route a fault here.) return } catch { log.debug("[unsolicited] decrypt miss at nonceSeq=%{public}d (delta=%{public}d): %{public}@", seq, delta, String(describing: error)) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 04c2c33..d532d48 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -137,8 +137,8 @@ class BluetoothManager: NSObject { /// measurement the RE asked for — is there a usable periodic wake?). private var lastAdvSeen: [String: Date] = [:] - /// FAULT-CAPTURE: last full advert (svcUUIDs|mfg) device-logged per peripheral, so we record each - /// DISTINCT advert once (captures the fault transition without flooding the device log). + /// Last full advert (svcUUIDs|mfg) device-logged per peripheral, so we record each DISTINCT advert + /// once (captures the fault transition without flooding the device log). private var lastLoggedAdvKey: [String: String] = [:] /// Isolated to `managerQueue` @@ -320,8 +320,8 @@ class BluetoothManager: NSObject { cm.connect(peripheral, options: nil) } - /// Issue a connect with CBConnectPeripheralOptionStartDelayKey and record the time, for the - /// timed-wake experiment. iOS holds the request for `delayedConnectProbeSeconds`, then connects. + /// Issue a connect with CBConnectPeripheralOptionStartDelayKey and record the time — the pump-provided + /// heartbeat wake. iOS holds the request for `delayedConnectProbeSeconds`, then connects. private func issueDelayedConnectProbe(_ peripheral: CBPeripheral) { // Never run the heartbeat probe during pairing — its connect/disconnect churn clobbers the // discovery scan (this blocked pairing a new pod after the old one was discarded). @@ -709,8 +709,8 @@ class BluetoothManager: NSObject { return } if BluetoothManager.lowPowerMonitorEnabled { - // Option 3: wake only on an alarm-state advertisement. Filter on the alarm UUID(s), no - // allowDuplicates. Takes precedence over the monitor/beacon scans. + // Low-power fault-watch: wake only on a fault-state advertisement. Filter on the alarm + // UUID(s) (C00A), no allowDuplicates. Takes precedence over the monitor scan. services = BluetoothManager.alarmServiceUUIDs options = [:] } else { @@ -872,10 +872,10 @@ extension BluetoothManager: CBCentralManagerDelegate { return mfg.subdata(in: (mfg.count - 7)..<(mfg.count - 3)) } - /// PROTOTYPE connectionless alert detection (§5 finding): read the pod's alert state straight from - /// its advertisement — no connection needed. Logs the status word and flags clear↔alert transitions. - /// TODO(stage 2): route a confirmed alert transition to the pump manager (raise/clear a pod alert) - /// once the per-alert bit mapping is confirmed across more alert types, to avoid false positives. + /// Connectionless fault/alert detection: read the pod's alarm state straight from its advertisement — + /// no connection needed. Decodes the status word (b2 = FaultEventCode, b3 = AlertSet bitmask) and, on + /// a fault/alert transition, surfaces it to the pump manager (which fetches status and raises the pod + /// alarm), then quiets the scan until it clears. private func detectPodAlertStatus(peripheral: CBPeripheral, advertisementData: [String: Any]) { guard let status = podStatusWord(from: advertisementData) else { return } let id = peripheral.identifier.uuidString @@ -939,8 +939,8 @@ extension BluetoothManager: CBCentralManagerDelegate { log.debug("%{public}@: %{public}@, %{public}@", #function, peripheral, advertisementData) - // Full advertisement dump for pod-adjacent frames — the raw material for §5 (normal↔alarm - // diff) and the "faults via advertisement" model. Captures every field, every time. + // Full advertisement dump for pod-adjacent frames — field data on what the pod advertises, and + // the input to the connectionless fault-detection path. Captures every field. let advSvcUUIDs = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]) ?? [] let isPodFrame = autoConnectIDs.contains(peripheral.identifier.uuidString) || PodAdvertisement(advertisementData, podType: podType) != nil if BluetoothManager.advertisementMonitorEnabled, isPodFrame { diff --git a/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift b/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift index cd42cc1..ee8c1d5 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift @@ -360,20 +360,19 @@ extension PeripheralManagerError { } } -// MARK: - PROTOTYPE: unsolicited (pod-initiated) fault listener — Stage 1 (capture + decrypt-log) +// MARK: - Unsolicited (pod-initiated) fault listener (opt-in diagnostic; default OFF) // // While CONNECTED, a pod can initiate a transfer (e.g. a fault/alert) without us having -// sent a command. Today those notifications are only buffered and then flushed -// (clearCommsQueues) before the next command, so we only learn of faults by polling -// GetStatus. This stage detects a pod-initiated transfer while idle, drives the EXISTING -// receive path to assemble the encrypted MessagePacket, and hands it to the delegate to -// decrypt + log. It intentionally does NOT mutate session sequence state and does NOT -// route alerts — its job is to confirm from field logs that pods push faults unsolicited -// and to capture the nonce-sequence behavior stage 2 needs. +// sent a command. Those notifications are normally buffered and then flushed +// (clearCommsQueues) before the next command. This listener detects a pod-initiated transfer +// while idle, drives the EXISTING receive path to assemble the encrypted MessagePacket, and +// hands it to the delegate to decrypt + log (keeping the session nonce in sync). It does NOT +// route alerts — production fault detection is the connectionless C00A advertisement scan. +// This is a BLE-protocol diagnostic for characterizing unsolicited pod pushes from field logs. extension PeripheralManager { - /// Opt-in diagnostic (default OFF): a passive listener that logs pod-initiated (unsolicited) frames - /// pushed over an active connection. It does NOT route alerts or mutate session sequence state — + /// Opt-in diagnostic (default OFF): a listener that logs pod-initiated (unsolicited) frames pushed + /// over an active connection (keeping the session nonce in sync). It does NOT route alerts — /// production fault detection is the connectionless C00A advertisement scan, not this. Ships off; /// enable via the UserDefaults key for BLE protocol diagnostics. static var unsolicitedFaultListenerEnabled: Bool { diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index e1b9cf1..fe19e38 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -82,7 +82,7 @@ class PeripheralManager: NSObject { weak var delegate: PeripheralManagerDelegate? - /// PROTOTYPE: armed only once an encrypted session is fully established (set by + /// Armed only once an encrypted session is fully established (set by /// BlePodComms). The unsolicited-fault listener must NOT engage during connect/session /// negotiation — that traffic looks like pod-initiated transfers (multi-byte handshakes /// whose first byte is 0x00) and false-triggered a disconnect loop. @@ -128,7 +128,7 @@ protocol PeripheralManagerDelegate: AnyObject { // Called from the PeripheralManager's queue func completeConfiguration(for manager: PeripheralManager) throws - /// PROTOTYPE (unsolicited-fault listener): a fully-assembled MessagePacket that + /// Unsolicited-fault listener (opt-in diagnostic): a fully-assembled MessagePacket that /// arrived UNSOLICITED — i.e. the pod initiated a transfer while we had no command /// in flight. The implementer (BlePodComms) holds the session keys and decrypts + /// logs it. Default is a no-op. Gated by `PeripheralManager.unsolicitedFaultListenerEnabled`. @@ -363,7 +363,7 @@ extension PeripheralManager { log.default("[connectOnDemand] connecting on demand (state=%{public}d, timeout=%{public}ds)", peripheral.state.rawValue, Int(timeout)) // Go fully dark for the connect: ANY concurrent scan (even non-allowDuplicates wildcard) // starves connection completion on iOS — connect-on-demand with a "helper" scan reliably - // TIMED OUT at 20s foreground, whereas the delayed-connect experiments (no scan) always + // TIMED OUT at 20s foreground, whereas a scan-free delayed connect always // completed. So stop scanning and let iOS complete the connect; the alarm scan resumes on // disconnect. (Latency without a scan is iOS's own reacquisition; measure it, optimize next.) let start = Date() From 1d434c69a1a2187f98ae07835c396e3ca6f616bb Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 12 Jul 2026 09:08:29 -0500 Subject: [PATCH 75/92] FULL BLE CAPTURE (revert before PR): O5-aware advert + all-characteristic capture Add bleCaptureEnabled (default ON this build) for high-fidelity RE: - Idle: wildcard advert scan (matches the pod in any state incl. O5 CE1F923D; no DASH-C00A assumption), allowDuplicates, keep-alive off so the pod keeps advertising. - didDiscover: log every distinct pod advert; gate detectPodAlertStatus to podType.isDash so the DASH iBeacon decode never runs against an O5 advert. - On connect: discover ALL services + characteristics, log each with properties, subscribe to every notify/indicate characteristic (not just command/data/heartbeat). - didUpdateValueFor: device-log every value update (char UUID + raw hex). Adds PeripheralManagerDelegate.logCaptureEvent -> BlePodComms.omnipodLogDeviceEvent. --- OmnipodKit/Bluetooth/BlePodComms.swift | 4 ++ OmnipodKit/Bluetooth/BluetoothManager.swift | 34 +++++++++++---- OmnipodKit/Bluetooth/PeripheralManager.swift | 46 +++++++++++++++++++- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index f2e81e8..f4b162f 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -874,6 +874,10 @@ extension BlePodComms: PodCommsSessionDelegate { // MARK: - Unsolicited (pod-initiated) fault decrypt + logging (opt-in diagnostic) extension BlePodComms { + func peripheralManager(_ manager: PeripheralManager, logCaptureEvent message: String) { + omnipodLogDeviceEvent(message) + } + func peripheralManager(_ manager: PeripheralManager, didReceiveUnsolicitedMessagePacket packet: MessagePacket) { podStateLock.lock() let mtsSnapshot = podState?.bleMessageTransportState diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index d532d48..c5d7352 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -215,6 +215,15 @@ class BluetoothManager: NSObject { UserDefaults.standard.object(forKey: "OmnipodKit.scanningEnabled") as? Bool ?? true } + /// FULL BLE CAPTURE (revert before PR): high-fidelity RE capture — while idle, run a WILDCARD advert + /// scan (matches the pod in ANY state, incl. O5's CE1F923D UUID; does NOT assume DASH C00A) with + /// keep-alive OFF so the pod stays advertising; on connect, discover ALL services + characteristics, + /// subscribe to EVERY notifiable/indicatable one, and device-log every advert and every value update. + /// Default ON for the O5-investigation build. + static var bleCaptureEnabled: Bool { + UserDefaults.standard.object(forKey: "OmnipodKit.bleCaptureEnabled") as? Bool ?? true + } + /// Start delay (seconds) for the delayed-connect probe. Note the real wake lands at StartDelay + /// an iOS reacquisition tail (~40s observed), so 300 → wake ~340s. static var delayedConnectProbeSeconds: Int { @@ -263,7 +272,7 @@ class BluetoothManager: NSObject { /// live and in-app commands are instant. On background we disconnect and resume the heartbeat probe. private var isAppForeground = false /// Cross-queue read for PeripheralManager's idle-disconnect (benign bool race, like everForeground). - var appIsForeground: Bool { isAppForeground } + var appIsForeground: Bool { isAppForeground && !BluetoothManager.bleCaptureEnabled } /// True once this PROCESS has ever been foregrounded. A [delayedConnect] with everFg=false means /// iOS ran this process entirely in the background — proof of a background wake/relaunch the user @@ -708,13 +717,19 @@ class BluetoothManager: NSObject { log.default("[connectOnDemand] scanning disabled — not starting a scan (scan-free connect mode)") return } - if BluetoothManager.lowPowerMonitorEnabled { - // Low-power fault-watch: wake only on a fault-state advertisement. Filter on the alarm - // UUID(s) (C00A), no allowDuplicates. Takes precedence over the monitor scan. + if BluetoothManager.bleCaptureEnabled { + // Full-capture: wildcard scan (matches the pod in any state, incl. O5's CE1F923D UUID — + // no DASH-C00A assumption) + allowDuplicates so we see the advert cadence and any state flip. + services = nil + options = [CBCentralManagerScanOptionAllowDuplicatesKey: true] + } else if BluetoothManager.lowPowerMonitorEnabled && podType.isDash { + // Low-power fault-watch (DASH only): wake on a fault-state advertisement — filter on the alarm + // UUID(s) (C00A), no allowDuplicates. C00A is DASH-specific, so never used for O5. services = BluetoothManager.alarmServiceUUIDs options = [:] } else { - // Monitor mode: filter on the pod's main service; allowDuplicates to see the advert cadence. + // Monitor mode: filter on the pod's main service (O5-aware via podScanServiceUUID); + // allowDuplicates to see the advert cadence. services = [serviceUUID] options = BluetoothManager.advertisementMonitorEnabled ? [CBCentralManagerScanOptionAllowDuplicatesKey: true] : [:] } @@ -943,7 +958,7 @@ extension BluetoothManager: CBCentralManagerDelegate { // the input to the connectionless fault-detection path. Captures every field. let advSvcUUIDs = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]) ?? [] let isPodFrame = autoConnectIDs.contains(peripheral.identifier.uuidString) || PodAdvertisement(advertisementData, podType: podType) != nil - if BluetoothManager.advertisementMonitorEnabled, isPodFrame { + if BluetoothManager.advertisementMonitorEnabled || BluetoothManager.bleCaptureEnabled, isPodFrame { let svcUUIDs = advSvcUUIDs.map { $0.uuidString }.joined(separator: ",") let mfg = (advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data)?.hexadecimalString ?? "-" let svcData = (advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data])? @@ -970,11 +985,14 @@ extension BluetoothManager: CBCentralManagerDelegate { } } } else if let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data, - BluetoothManager.advertisementMonitorEnabled { + BluetoothManager.advertisementMonitorEnabled, !BluetoothManager.bleCaptureEnabled { + // Suppressed during wildcard capture — this would fire for every nearby BLE device. log.default("[SCAN] ManufacturerData: %{public}@ (%{public}d bytes)", mfgData.hexadecimalString, mfgData.count) } - if isPodFrame { + // Connectionless alarm decode is DASH-specific (parses the DASH iBeacon status word). O5 encodes + // state differently (see the capture) — never run the DASH decode against an O5 advert. + if isPodFrame && podType.isDash { detectPodAlertStatus(peripheral: peripheral, advertisementData: advertisementData) // Fresh-discovery connect: we just heard the pod — stop scanning and connect NOW on this // fresh advertisement (fast) instead of waiting out iOS's cold reacquisition (~16s). diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index fe19e38..9dc6f49 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -133,6 +133,14 @@ protocol PeripheralManagerDelegate: AnyObject { /// in flight. The implementer (BlePodComms) holds the session keys and decrypts + /// logs it. Default is a no-op. Gated by `PeripheralManager.unsolicitedFaultListenerEnabled`. func peripheralManager(_ manager: PeripheralManager, didReceiveUnsolicitedMessagePacket packet: MessagePacket) + + /// Route a device-log line (Issue Report) — used by the full BLE capture to record discovered + /// characteristics and every value update. Default no-op. + func peripheralManager(_ manager: PeripheralManager, logCaptureEvent message: String) +} + +extension PeripheralManagerDelegate { + func peripheralManager(_ manager: PeripheralManager, logCaptureEvent message: String) { } } extension PeripheralManagerDelegate { @@ -244,6 +252,36 @@ extension PeripheralManager { try setNotifyValue(true, for: characteristic, timeout: discoveryTimeout) } } + + if BluetoothManager.bleCaptureEnabled { + try? captureAllServicesAndCharacteristics(timeout: discoveryTimeout) + } + } + + /// FULL BLE CAPTURE (revert before PR): discover ALL services + characteristics and subscribe to every + /// notifiable/indicatable one, logging each — so unsolicited pushes anywhere on the pod are captured in + /// didUpdateValueFor. Best-effort; errors here must never break a real session. + private func captureAllServicesAndCharacteristics(timeout: TimeInterval) throws { + try runCommand(timeout: timeout) { + addCondition(.discoverServices) + peripheral.discoverServices(nil) // nil = all services + } + for service in peripheral.services ?? [] { + try runCommand(timeout: timeout) { + addCondition(.discoverCharacteristicsForService(serviceUUID: service.uuid)) + peripheral.discoverCharacteristics(nil, for: service) // nil = all characteristics + } + for ch in service.characteristics ?? [] { + let p = ch.properties + let flags = [p.contains(.notify) ? "notify" : nil, p.contains(.indicate) ? "indicate" : nil, + p.contains(.read) ? "read" : nil, p.contains(.write) ? "write" : nil, + p.contains(.writeWithoutResponse) ? "writeNR" : nil].compactMap { $0 }.joined(separator: ",") + delegate?.peripheralManager(self, logCaptureEvent: "[capture] char service=\(service.uuid.uuidString) char=\(ch.uuid.uuidString) props=[\(flags)]") + if (p.contains(.notify) || p.contains(.indicate)), !ch.isNotifying { + try? setNotifyValue(true, for: ch, timeout: timeout) + } + } + } } } @@ -531,8 +569,14 @@ extension PeripheralManager: CBPeripheralDelegate { } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + if BluetoothManager.bleCaptureEnabled { + // Full capture: log EVERY value update on EVERY subscribed characteristic (incl. ones outside + // the pod profile that we subscribed to below), so any unsolicited push lands in the log. + let hex = characteristic.value?.hexadecimalString ?? "-" + delegate?.peripheralManager(self, logCaptureEvent: "[capture] notify char=\(characteristic.uuid.uuidString) len=\(characteristic.value?.count ?? 0) value=\(hex)") + } commandLock.lock() - + if let macro = configuration.valueUpdateMacros[characteristic.uuid] { macro(self) } From 011bfcf48418ecfc29816a691008ca47d22bf5be Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 12 Jul 2026 09:24:34 -0500 Subject: [PATCH 76/92] capture: never subscribe the pod's command/data/heartbeat chars (fixes O5 beeps) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-capture subscribe-all ran during applyConfiguration and enabled notify on the command/data characteristics BEFORE the O5 AID handshake's enableNotifications, breaking O5 message sequencing (incorrectResponse -> pod drops link -> no beeps). Exclude the profile's own command/data/heartbeat characteristics from the capture subscribe (the session flow owns their notify timing); only subscribe to unexpected characteristics. O5 has none, so this is now a no-op for O5 — commands work again. --- OmnipodKit/Bluetooth/PeripheralManager.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index 9dc6f49..7cab407 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -262,6 +262,11 @@ extension PeripheralManager { /// notifiable/indicatable one, logging each — so unsolicited pushes anywhere on the pod are captured in /// didUpdateValueFor. Best-effort; errors here must never break a real session. private func captureAllServicesAndCharacteristics(timeout: TimeInterval) throws { + // NEVER subscribe to the pod's own command/data/heartbeat characteristics here — the session + // flow (enableNotifications, during the O5 AID handshake) owns their notify timing, and enabling + // them early breaks O5's message sequencing. We only subscribe to UNEXPECTED notifiable chars. + let profileChars = Set([profile.commandCharacteristicUUID, profile.dataCharacteristicUUID, + profile.heartbeatCharacteristicUUID].compactMap { $0 }) try runCommand(timeout: timeout) { addCondition(.discoverServices) peripheral.discoverServices(nil) // nil = all services @@ -277,7 +282,7 @@ extension PeripheralManager { p.contains(.read) ? "read" : nil, p.contains(.write) ? "write" : nil, p.contains(.writeWithoutResponse) ? "writeNR" : nil].compactMap { $0 }.joined(separator: ",") delegate?.peripheralManager(self, logCaptureEvent: "[capture] char service=\(service.uuid.uuidString) char=\(ch.uuid.uuidString) props=[\(flags)]") - if (p.contains(.notify) || p.contains(.indicate)), !ch.isNotifying { + if (p.contains(.notify) || p.contains(.indicate)), !ch.isNotifying, !profileChars.contains(ch.uuid) { try? setNotifyValue(true, for: ch, timeout: timeout) } } From 42ee135ac245ea22296d136873cf10f55b564fde Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 12 Jul 2026 09:28:47 -0500 Subject: [PATCH 77/92] capture: re-add Trigger Test Alert (DASH + O5) to drive an alarm-state advert on command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the debug test-alert trigger removed in cleanup 2/n: schedules an expiration-reminder alert ~60s out (configureAlerts, pod-type-agnostic) so we can capture the pod's alarm-state advertisement non-destructively — needed to see whether the O5 service UUID (or only the mfg data) changes on an alarm. Behind the diagnostics screen; revert before PR. --- OmnipodKit/PumpManager/OmniPumpManager.swift | 32 +++++++++++++++++++ .../Views/PodDiagnosticsView.swift | 11 +++++++ 2 files changed, 43 insertions(+) diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index da2f78a..ec20d14 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -1834,6 +1834,38 @@ extension OmniPumpManager { } } + /// CAPTURE (revert before PR): schedule a real non-fault pod alert (expiration reminder) to fire + /// ~60s from now so we can capture the pod's alarm-state advertisement on command. Works for DASH + /// and O5 (configureAlerts is pod-type-agnostic via the session). Non-destructive — acknowledge the + /// alert away afterward. + func triggerTestAlert() async throws { + guard self.hasActivePod else { + throw OmniPumpManagerError.noPodPaired + } + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.runSession(withName: "Trigger Test Alert") { (result) in + switch result { + case .success(let session): + self.handleSilencePodEnd(session: session) + let podTime = self.podTime + let alertPodTime = podTime + TimeInterval(seconds: 60) // fire ~60s from now (mirrors updateExpirationReminder math) + let testAlert = PodAlert.expirationReminder(offset: podTime, absAlertTime: alertPodTime, silent: false) + do { + let beepBlock = self.beepMessageBlock(beepType: .beep) + let _ = try session.configureAlerts([testAlert], beepBlock: beepBlock) + self.log.default("[testAlert] scheduled expirationReminder to fire in ~60s (podTime=%{public}@, alertPodTime=%{public}@)", podTime.timeIntervalStr, alertPodTime.timeIntervalStr) + self.logDeviceCommunication("[testAlert] scheduled expirationReminder to fire in ~60s (alertPodTime=\(alertPodTime.timeIntervalStr))", type: .connection) + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + // Called on the main thread. // The UI is responsible for serializing calls to this method; // it does not handle concurrent calls. diff --git a/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift b/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift index abd3516..38fbb0f 100644 --- a/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift @@ -21,6 +21,7 @@ protocol DiagnosticCommands { func readTriggeredAlerts() async throws -> String func getDetailedStatus() async throws -> DetailedStatus func pumpManagerDetails() -> String + func triggerTestAlert() async throws } struct PodDiagnosticsView: View { @@ -47,6 +48,16 @@ struct PodDiagnosticsView: View { } .disabled(!podOk) + // CAPTURE (revert before PR): fire a real non-fault alert ~60s out so we can capture the + // pod's alarm-state advertisement (DASH + O5) on command, non-destructively. + Button(action: { + Task { try? await diagnosticCommands.triggerTestAlert() } + }) { + Text("Trigger Test Alert (debug, fires in ~60s)") + .foregroundColor(Color.primary) + } + .disabled(!podOk) + NavigationLink(destination: ReadPodInfoView( title: LocalizedString("Read Pulse Log", comment: "Text for read pulse log title"), actionString: LocalizedString("Reading Pulse Log...", comment: "Text for read pulse log action"), From 8ed36cc3e931755553a0520dcfb7980b90e4449d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 12 Jul 2026 14:09:47 -0500 Subject: [PATCH 78/92] test: arm connected-state periodic-status nudge (SN0.0=30) + stay connected Wire up a test of the pod-driven periodic status the RE engineer characterized: SN0.0= arms a pod-side timer; the pod then pushes a clear 5-byte CMD (2441) indication every period (300s Auto / 60s fault), which a connected app can use as a heartbeat/fault wake. New periodicStatusEnabled flag (default ON): - gate configurePeriodicStatus on it (was unsolicitedFaultListenerEnabled), interval 30s - force keep-alive on so we stay connected to observe the pushes - device-log every value update so the CMD nudge is captured Revert before PR. --- OmnipodKit/Bluetooth/BlePodComms.swift | 4 ++-- OmnipodKit/Bluetooth/BluetoothManager.swift | 10 +++++++++- OmnipodKit/Bluetooth/PeripheralManager.swift | 5 +++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index f4b162f..fdc35ea 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -364,7 +364,7 @@ class BlePodComms: PodComms { /// seconds), response prefix `N0.0=`. If REJECTED, try: a different feature/attr split, binary /// vs ASCII seconds, or the standard `S0.0=` envelope. private func configurePeriodicStatus() { - guard PeripheralManager.unsolicitedFaultListenerEnabled else { return } + guard BluetoothManager.periodicStatusEnabled else { return } guard let manager = manager, podState != nil else { return } // Only on a healthy, fully-set-up pod. Never during pairing/activation, and never // on a faulted pod — otherwise this fires on every reconnect of a screaming pod and @@ -378,7 +378,7 @@ class BlePodComms: PodComms { return } - let intervalSeconds = 10 // ~10s so the first push arrives quickly while testing (was 60) + let intervalSeconds = 30 // ~30s so pushes arrive quickly while testing (RE-confirmed cadences are 300s Auto / 60s fault) let transport = BlePodMessageTransport(manager: manager, myId: myId, podId: podId, state: podState!.bleMessageTransportState, signingKey: podState?.signingKey) transport.messageLogger = messageLogger diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index c5d7352..331fe49 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -224,6 +224,14 @@ class BluetoothManager: NSObject { UserDefaults.standard.object(forKey: "OmnipodKit.bleCaptureEnabled") as? Bool ?? true } + /// PERIODIC-STATUS TEST (revert before PR): after session establishment, arm the pod's connected-state + /// periodic-status nudge (SN0.0=) and STAY CONNECTED (force keep-alive on) so we can observe + /// the pod-initiated CMD indication push on a timer. Every value update is device-logged. Default ON + /// for this test build. + static var periodicStatusEnabled: Bool { + UserDefaults.standard.object(forKey: "OmnipodKit.periodicStatusEnabled") as? Bool ?? true + } + /// Start delay (seconds) for the delayed-connect probe. Note the real wake lands at StartDelay + /// an iOS reacquisition tail (~40s observed), so 300 → wake ~340s. static var delayedConnectProbeSeconds: Int { @@ -272,7 +280,7 @@ class BluetoothManager: NSObject { /// live and in-app commands are instant. On background we disconnect and resume the heartbeat probe. private var isAppForeground = false /// Cross-queue read for PeripheralManager's idle-disconnect (benign bool race, like everForeground). - var appIsForeground: Bool { isAppForeground && !BluetoothManager.bleCaptureEnabled } + var appIsForeground: Bool { isAppForeground && (!BluetoothManager.bleCaptureEnabled || BluetoothManager.periodicStatusEnabled) } /// True once this PROCESS has ever been foregrounded. A [delayedConnect] with everFg=false means /// iOS ran this process entirely in the background — proof of a background wake/relaunch the user diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index 7cab407..46a3aca 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -574,9 +574,10 @@ extension PeripheralManager: CBPeripheralDelegate { } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - if BluetoothManager.bleCaptureEnabled { + if BluetoothManager.bleCaptureEnabled || BluetoothManager.periodicStatusEnabled { // Full capture: log EVERY value update on EVERY subscribed characteristic (incl. ones outside - // the pod profile that we subscribed to below), so any unsolicited push lands in the log. + // the pod profile that we subscribed to below), so any unsolicited push — e.g. the pod's + // periodic-status CMD nudge — lands in the log. let hex = characteristic.value?.hexadecimalString ?? "-" delegate?.peripheralManager(self, logCaptureEvent: "[capture] notify char=\(characteristic.uuid.uuidString) len=\(characteristic.value?.count ?? 0) value=\(hex)") } From d37ae40d7686024c7096f1f4461714701d32311b Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 12 Jul 2026 16:38:01 -0500 Subject: [PATCH 79/92] periodic-status test: device-log arming, stop churn, skip O5 (needs AID envelope) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Device-log the [periodic] arm/skip/ACK lines (were os_log only, so invisible in Issue Reports — we were blind to whether it armed). - Revert the forced keep-alive: a failed arm no longer churns the connection; an armed pod is expected to hold the link itself. - Skip arming for O5: the DASH SN0.0= SLPE form isn't valid for O5 (zero SN frames reached the O5 pod; O5 uses INS. AID commands), so don't send a wrong command to the running O5 pod. Wire the O5 AID envelope through sendO5AidCommands once the RE engineer provides the INS.-framed form. DASH arming unchanged. --- OmnipodKit/Bluetooth/BlePodComms.swift | 13 +++++++++++++ OmnipodKit/Bluetooth/BluetoothManager.swift | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index fdc35ea..8571cb3 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -371,10 +371,20 @@ class BlePodComms: PodComms { // adds SN0.0= writes to a connect/disconnect loop. guard podState?.isSetupComplete == true else { log.default("[periodic] skip registration: pod setup not complete") + omnipodLogDeviceEvent("[periodic] skip: pod setup not complete") return } guard podState?.fault == nil else { log.default("[periodic] skip registration: pod is faulted") + omnipodLogDeviceEvent("[periodic] skip: pod is faulted") + return + } + // O5 arms periodic status via its AID (INS.) command envelope, NOT the DASH SN0.0= SLPE form. + // Sending the DASH command to an O5 pod is a wrong command it may reject (dropping the session), + // so skip it until the O5 AID envelope is wired through sendO5AidCommands. (DASH proceeds below.) + guard podType.isDash else { + log.default("[periodic] skip: O5 periodic-status needs the AID (INS.) envelope — not implemented") + omnipodLogDeviceEvent("[periodic] skip: O5 needs the AID (INS.) envelope; not sending DASH SN0.0= to an O5 pod") return } @@ -400,12 +410,15 @@ class BlePodComms: PodComms { transport.nonceSeq, transport.msgSeq, transport.messageNumber, transport.eapSeq, podState?.bleIdentifier ?? "?") log.default("[periodic] register (fire-and-forget SLPE): keys=%{public}@ seconds=%{public}d wrappedHex=%{public}@ — success=write-ACK; watch [unsolicited] for a push in ~%{public}ds", keys.joined(), intervalSeconds, wrapped.hexadecimalString, intervalSeconds) + omnipodLogDeviceEvent("[periodic] arming SN0.0=\(intervalSeconds) (podType=\(podType.isO5 ? "O5" : "DASH")) wrappedHex=\(wrapped.hexadecimalString)") do { try transport.sendSlpeCommandFireAndForget(keys: keys, payloads: payloads) log.default("[periodic] register write ACK'd — pod received SN0.0=%{public}d. Expecting an unsolicited push in ~%{public}ds (the push, not a reply, is the success signal).", intervalSeconds, intervalSeconds) + omnipodLogDeviceEvent("[periodic] arm write ACK'd (SN0.0=\(intervalSeconds)) — watch for a push in ~\(intervalSeconds)s") } catch { log.error("[periodic] register send NOT ACK'd: %{public}@", String(describing: error)) + omnipodLogDeviceEvent("[periodic] arm write NOT ACK'd: \(String(describing: error))") } log.default("[periodic] post-register transport state: nonceSeq=%{public}d msgSeq=%{public}d messageNumber=%{public}d", transport.nonceSeq, transport.msgSeq, transport.messageNumber) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 331fe49..051f6df 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -280,7 +280,7 @@ class BluetoothManager: NSObject { /// live and in-app commands are instant. On background we disconnect and resume the heartbeat probe. private var isAppForeground = false /// Cross-queue read for PeripheralManager's idle-disconnect (benign bool race, like everForeground). - var appIsForeground: Bool { isAppForeground && (!BluetoothManager.bleCaptureEnabled || BluetoothManager.periodicStatusEnabled) } + var appIsForeground: Bool { isAppForeground && !BluetoothManager.bleCaptureEnabled } /// True once this PROCESS has ever been foregrounded. A [delayedConnect] with everFg=false means /// iOS ran this process entirely in the background — proof of a background wake/relaunch the user From d7210c28729a2e2a599a28aa1928f53695896cb7 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 13 Jul 2026 14:28:35 -0500 Subject: [PATCH 80/92] periodic-status: use SN2.0= (RE engineer) for DASH+O5; stay connected to observe push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RE engineer (2026-07-13): the periodic-status arm is the AID Set SN2.0= (S=Set, N2=periodic namespace + Status command token 2, .0=attr 0, =decimal seconds). Our earlier SN0.0= used the undefined command token 0. Change to SET-only SN2.0=, enable for O5 (drop the DASH-only skip), and re-enable forced keep-alive so the armed connection persists to observe the pod's periodic CMD push. Still a best-guess per the RE — the device-logged [periodic] arm result + any 2441 pushes will tell us. Note: if the arm is rejected the connection will churn (force-quit). --- OmnipodKit/Bluetooth/BlePodComms.swift | 31 ++++++++------------- OmnipodKit/Bluetooth/BluetoothManager.swift | 2 +- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index 8571cb3..4a0610c 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -379,15 +379,6 @@ class BlePodComms: PodComms { omnipodLogDeviceEvent("[periodic] skip: pod is faulted") return } - // O5 arms periodic status via its AID (INS.) command envelope, NOT the DASH SN0.0= SLPE form. - // Sending the DASH command to an O5 pod is a wrong command it may reject (dropping the session), - // so skip it until the O5 AID envelope is wired through sendO5AidCommands. (DASH proceeds below.) - guard podType.isDash else { - log.default("[periodic] skip: O5 periodic-status needs the AID (INS.) envelope — not implemented") - omnipodLogDeviceEvent("[periodic] skip: O5 needs the AID (INS.) envelope; not sending DASH SN0.0= to an O5 pod") - return - } - let intervalSeconds = 30 // ~30s so pushes arrive quickly while testing (RE-confirmed cadences are 300s Auto / 60s fault) let transport = BlePodMessageTransport(manager: manager, myId: myId, podId: podId, state: podState!.bleMessageTransportState, signingKey: podState?.signingKey) @@ -397,25 +388,25 @@ class BlePodComms: PodComms { podState!.bleMessageTransportState = BleMessageTransportState(ck: transport.ck, noncePrefix: transport.noncePrefix, msgSeq: transport.msgSeq, nonceSeq: transport.nonceSeq, messageNumber: transport.messageNumber) } - // SN0.0= is a STANDARD S…= command (SLPE, 2-byte length-prefixed): the interval value is - // length-prefixed under "SN0.0=", the trailing get-key ",GN0.0" is empty. Registration is - // FIRE-AND-FORGET — the pod sends NO synchronous reply, so we do not read one (reading always - // timed out with emptyValue, a false negative). Success = the write is ACK'd; the real - // confirmation is the pod's first unsolicited PUSH ~intervalSeconds later, caught by the - // unsolicited listener. - let keys = ["SN0.0=", ",GN0.0"] - let payloads = [Data(String(intervalSeconds).utf8), Data()] + // Arm periodic Status via the AID Set command SN2.0= (RE engineer, 2026-07-13): + // S = AID Set, N2 = periodic namespace N + command token 2 (= Status), .0 = attribute 0, + // = = decimal-ASCII period. (Our earlier SN0.0= used the undefined command token 0.) + // SET-only, SLPE-wrapped, sent through the encrypted transport (Type-4 signed for O5). + // FIRE-AND-FORGET — the pod sends NO synchronous reply; success = the write is ACK'd, and the real + // confirmation is the pod's first unsolicited PUSH ~intervalSeconds later. + let keys = ["SN2.0="] + let payloads = [Data(String(intervalSeconds).utf8)] let wrapped = StringLengthPrefixEncoding.formatKeys(keys: keys, payloads: payloads) log.default("[periodic] pre-register transport state: nonceSeq=%{public}d msgSeq=%{public}d messageNumber=%{public}d eapSeq=%{public}d bleId=%{public}@", transport.nonceSeq, transport.msgSeq, transport.messageNumber, transport.eapSeq, podState?.bleIdentifier ?? "?") log.default("[periodic] register (fire-and-forget SLPE): keys=%{public}@ seconds=%{public}d wrappedHex=%{public}@ — success=write-ACK; watch [unsolicited] for a push in ~%{public}ds", keys.joined(), intervalSeconds, wrapped.hexadecimalString, intervalSeconds) - omnipodLogDeviceEvent("[periodic] arming SN0.0=\(intervalSeconds) (podType=\(podType.isO5 ? "O5" : "DASH")) wrappedHex=\(wrapped.hexadecimalString)") + omnipodLogDeviceEvent("[periodic] arming SN2.0=\(intervalSeconds) (podType=\(podType.isO5 ? "O5" : "DASH")) wrappedHex=\(wrapped.hexadecimalString)") do { try transport.sendSlpeCommandFireAndForget(keys: keys, payloads: payloads) - log.default("[periodic] register write ACK'd — pod received SN0.0=%{public}d. Expecting an unsolicited push in ~%{public}ds (the push, not a reply, is the success signal).", + log.default("[periodic] register write ACK'd — pod received SN2.0=%{public}d. Expecting an unsolicited push in ~%{public}ds (the push, not a reply, is the success signal).", intervalSeconds, intervalSeconds) - omnipodLogDeviceEvent("[periodic] arm write ACK'd (SN0.0=\(intervalSeconds)) — watch for a push in ~\(intervalSeconds)s") + omnipodLogDeviceEvent("[periodic] arm write ACK'd (SN2.0=\(intervalSeconds)) — watch for a push in ~\(intervalSeconds)s") } catch { log.error("[periodic] register send NOT ACK'd: %{public}@", String(describing: error)) omnipodLogDeviceEvent("[periodic] arm write NOT ACK'd: \(String(describing: error))") diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 051f6df..331fe49 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -280,7 +280,7 @@ class BluetoothManager: NSObject { /// live and in-app commands are instant. On background we disconnect and resume the heartbeat probe. private var isAppForeground = false /// Cross-queue read for PeripheralManager's idle-disconnect (benign bool race, like everForeground). - var appIsForeground: Bool { isAppForeground && !BluetoothManager.bleCaptureEnabled } + var appIsForeground: Bool { isAppForeground && (!BluetoothManager.bleCaptureEnabled || BluetoothManager.periodicStatusEnabled) } /// True once this PROCESS has ever been foregrounded. A [delayedConnect] with everFg=false means /// iOS ran this process entirely in the background — proof of a background wake/relaunch the user From 242052fee6610686974699689871f0a9ed71cfed Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 13 Jul 2026 16:48:09 -0500 Subject: [PATCH 81/92] periodic-status test: listen for adverts after arm (drop keep-alive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test hypothesis (user): the SN2.0= arm may make the pod signal 'status ready' via its ADVERTISEMENT (svc UUID / mfg change) rather than a connected CMD push — and an advert UUID change is what iOS can background-wake on. Stop forcing keep-alive so the pod idle-disconnects after the arm and the wildcard advert scan runs; watch for a ~30s advert change tied to the arm. Arm (SN2.0=30) still fires on each connect. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 331fe49..051f6df 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -280,7 +280,7 @@ class BluetoothManager: NSObject { /// live and in-app commands are instant. On background we disconnect and resume the heartbeat probe. private var isAppForeground = false /// Cross-queue read for PeripheralManager's idle-disconnect (benign bool race, like everForeground). - var appIsForeground: Bool { isAppForeground && (!BluetoothManager.bleCaptureEnabled || BluetoothManager.periodicStatusEnabled) } + var appIsForeground: Bool { isAppForeground && !BluetoothManager.bleCaptureEnabled } /// True once this PROCESS has ever been foregrounded. A [delayedConnect] with everFg=false means /// iOS ran this process entirely in the background — proof of a background wake/relaunch the user From 0754142fccfa792fb85a3b2460d4cb61c2120aa2 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 14 Jul 2026 20:19:58 -0500 Subject: [PATCH 82/92] Reading-scheduled StartDelay heartbeat + foreground keep-alive connection policy Replace the fixed-interval BLE heartbeat probe with one scheduled from the CGM reading cadence, driven by the new LoopKit PumpManager.setBLEHeartbeatRequest API (PumpHeartbeatRequest: last CGM reading date + expected interval). - BluetoothManager.setHeartbeatRequest: compute the StartDelay so the wake lands no earlier than lastCGMReading + expectedInterval + buffer (20s, tunable), with a 60s floor. Refreshed each CGM reading; self-correcting via the immediate re-arm plus the existing 2-min heartbeat throttle. - OmniPumpManager: implement setBLEHeartbeatRequest; bridge legacy setMustProvideBLEHeartbeat through it. BlePodComms forwards the request. - Connection policy (DASH + O5): re-enable foreground keep-alive / background disconnect by defaulting bleCaptureEnabled off; widen the idle-disconnect window 4s -> 15s so a Loop cycle's status read and follow-up dose share one connection. - Back-burner the advertising/indication periodic-status experiment: default periodicStatusEnabled off. Keep DASH C00A advertising fault detection and the StartDelay heartbeat. Scaffolding (bleCapture, periodicStatus, triggerTestAlert) remains behind now-off flags for a later cleanup commit. --- OmnipodKit/Bluetooth/BlePodComms.swift | 8 +- OmnipodKit/Bluetooth/BluetoothManager.swift | 92 +++++++++++++++----- OmnipodKit/Bluetooth/PeripheralManager.swift | 6 +- OmnipodKit/PumpManager/OmniPumpManager.swift | 24 +++-- 4 files changed, 93 insertions(+), 37 deletions(-) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index 4a0610c..8ffdc1a 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -55,10 +55,10 @@ class BlePodComms: PodComms { super.forgetPod() } - /// Enable/disable the pump-provided BLE heartbeat (delayed-connect loop). Driven by - /// OmniPumpManager.setMustProvideBLEHeartbeat — used only when the CGM can't provide a heartbeat. - func setProvidesHeartbeat(_ enabled: Bool) { - bluetoothManager?.setProvidesHeartbeat(enabled) + /// Enable/disable and schedule the pump-provided BLE heartbeat (delayed-connect loop). Driven by + /// OmniPumpManager.setBLEHeartbeatRequest — used only when the CGM can't provide a heartbeat. + func setHeartbeatRequest(_ request: PumpHeartbeatRequest?) { + bluetoothManager?.setHeartbeatRequest(request) } // Removes references to the bluetoothManager to avoid future diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 051f6df..fa2f6ab 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -221,7 +221,7 @@ class BluetoothManager: NSObject { /// subscribe to EVERY notifiable/indicatable one, and device-log every advert and every value update. /// Default ON for the O5-investigation build. static var bleCaptureEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.bleCaptureEnabled") as? Bool ?? true + UserDefaults.standard.object(forKey: "OmnipodKit.bleCaptureEnabled") as? Bool ?? false } /// PERIODIC-STATUS TEST (revert before PR): after session establishment, arm the pod's connected-state @@ -229,15 +229,29 @@ class BluetoothManager: NSObject { /// the pod-initiated CMD indication push on a timer. Every value update is device-logged. Default ON /// for this test build. static var periodicStatusEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.periodicStatusEnabled") as? Bool ?? true + UserDefaults.standard.object(forKey: "OmnipodKit.periodicStatusEnabled") as? Bool ?? false } - /// Start delay (seconds) for the delayed-connect probe. Note the real wake lands at StartDelay + - /// an iOS reacquisition tail (~40s observed), so 300 → wake ~340s. + /// Fallback start delay (seconds) for the delayed-connect probe when Loop hasn't supplied a heartbeat + /// schedule (no `heartbeatTargetDate`). Normally the delay is computed from the CGM reading schedule — + /// see `issueDelayedConnectProbe`. Note the real wake lands at StartDelay + an iOS reacquisition tail + /// (~40s observed), so 300 → wake ~340s. static var delayedConnectProbeSeconds: Int { (UserDefaults.standard.object(forKey: "OmnipodKit.delayedConnectProbeSeconds") as? Int) ?? 300 } + /// Buffer (seconds) added after the next expected CGM reading when scheduling the heartbeat, so a + /// remote/network CGM value has time to be fetched and stored before the heartbeat drives a Loop cycle. + static var heartbeatBufferSeconds: TimeInterval { + (UserDefaults.standard.object(forKey: "OmnipodKit.heartbeatBufferSeconds") as? Double) ?? 20 + } + + /// Floor (seconds) for the computed StartDelay, so a stale/overdue reading target can't produce a + /// near-zero delay that immediately reconnects. Overdue targets retry at this cadence. + static var heartbeatMinDelaySeconds: TimeInterval { + (UserDefaults.standard.object(forKey: "OmnipodKit.heartbeatMinDelaySeconds") as? Double) ?? 60 + } + /// Candidate DASH alarm-state service UUIDs to filter on in low-power mode. /// - `C005`: CONFIRMED 16-bit alarm 2nd-UUID on this pod (expiration reminder). Extend as more /// alert/alarm types are captured. @@ -263,6 +277,14 @@ class BluetoothManager: NSObject { /// so didDiscover doesn't re-issue during the wait; the issue timestamp measures the true delay. private var delayedProbeInFlight = false private var delayedProbeIssuedAt: Date? + /// StartDelay (seconds) of the probe currently in flight, for latency logging in didConnect. + private var delayedProbeDelay: TimeInterval? + + /// Target time for the next pump-provided heartbeat: the probe's StartDelay is computed so the wake + /// lands no earlier than this. Recomputed from Loop's `PumpHeartbeatRequest` each time it updates + /// (i.e. after each CGM reading), so the cadence tracks the actual reading schedule. nil = no schedule + /// supplied (fall back to `delayedConnectProbeSeconds`). managerQueue-isolated. + private var heartbeatTargetDate: Date? /// True while a real command's connect owns the link (connect-on-demand). The heartbeat probe and /// a command connect must never be outstanding together — a command preempts the probe and, while @@ -295,33 +317,48 @@ class BluetoothManager: NSObject { private var heartbeatEnabled = false /// The delayed-connect (StartDelay) heartbeat probe runs exactly when Loop asks the pump to provide - /// the BLE heartbeat — i.e. `heartbeatEnabled`, set via PumpManager.setMustProvideBLEHeartbeat. No + /// the BLE heartbeat — i.e. `heartbeatEnabled`, set via PumpManager.setBLEHeartbeatRequest. No /// other gate: whenever the host needs a pump-provided heartbeat, the connect-delay probe is active. /// Isolated to managerQueue (all probe call sites run there). private var delayedConnectProbeActive: Bool { heartbeatEnabled } - /// Enable/disable the pump-provided heartbeat (delayed-connect loop). Driven by - /// PumpManager.setMustProvideBLEHeartbeat. - func setProvidesHeartbeat(_ enabled: Bool) { + /// Enable/disable and schedule the pump-provided heartbeat (delayed-connect loop). Driven by + /// PumpManager.setBLEHeartbeatRequest. `request == nil` disables it (fall back to connect-on-demand + + /// alarm scan). When non-nil, the next-heartbeat target is (lastCGMReading + expectedInterval + buffer); + /// this is refreshed on every call (e.g. after each CGM reading) so the cadence tracks the reading + /// schedule. Refreshing the target while a probe is already in flight does NOT churn it — the in-flight + /// probe completes and the next one picks up the new target. + func setHeartbeatRequest(_ request: PumpHeartbeatRequest?) { managerQueue.async { - guard self.heartbeatEnabled != enabled else { return } + let enabled = request != nil + if let request = request { + let base = request.lastCGMReadingDate ?? Date() + self.heartbeatTargetDate = base.addingTimeInterval(request.expectedCGMReadingInterval + BluetoothManager.heartbeatBufferSeconds) + } else { + self.heartbeatTargetDate = nil + } + let wasEnabled = self.heartbeatEnabled self.heartbeatEnabled = enabled let pid = ProcessInfo.processInfo.processIdentifier - self.log.default("[heartbeat] pid=%{public}d providesHeartbeat=%{public}@", pid, String(enabled)) - self.connectionDelegate?.omnipodLogDeviceEvent("[heartbeat] pid=\(pid) providesHeartbeat=\(enabled)") + let targetDesc = self.heartbeatTargetDate.map { String(format: "%.0fs", $0.timeIntervalSinceNow) } ?? "-" + self.log.default("[heartbeat] pid=%{public}d providesHeartbeat=%{public}@ targetIn=%{public}@", pid, String(enabled), targetDesc) + self.connectionDelegate?.omnipodLogDeviceEvent("[heartbeat] pid=\(pid) providesHeartbeat=\(enabled) targetIn=\(targetDesc)") if enabled { - // Kick off the delayed-connect loop against the known autoconnect pod (nil if no active - // pod — don't probe a stale/discarded device). + // (Re)arm against the known autoconnect pod (nil if no active pod — don't probe a + // stale/discarded device). No-ops if a probe is already in flight. if let peripheral = self.keepAlivePeripheral { self.issueDelayedConnectProbe(peripheral) } - } else { - // Stop the loop; fall back to connect-on-demand + alarm scan. + } else if wasEnabled { + // Stop the loop; fall back to connect-on-demand + alarm scan. Don't drop a live connection + // while foregrounded (keep-alive owns it then). self.delayedProbeInFlight = false - for device in self.devices where device.manager.peripheral.state != .disconnected { - self.manager.cancelPeripheralConnection(device.manager.peripheral) + if !self.isAppForeground { + for device in self.devices where device.manager.peripheral.state != .disconnected { + self.manager.cancelPeripheralConnection(device.manager.peripheral) + } } self.resumeScanIfNeeded() } @@ -345,17 +382,22 @@ class BluetoothManager: NSObject { guard !discoveryModeEnabled else { return } guard delayedConnectProbeActive, !delayedProbeInFlight, !commandConnectInFlight, peripheral.state == .disconnected else { return } - let delay = BluetoothManager.delayedConnectProbeSeconds + // Compute the StartDelay so the wake lands no earlier than the next-heartbeat target + // (lastCGMReading + expectedInterval + buffer). Floored so an overdue target can't collapse to a + // near-zero delay. Falls back to the fixed probe interval when Loop hasn't supplied a schedule. + let target = heartbeatTargetDate ?? Date().addingTimeInterval(TimeInterval(BluetoothManager.delayedConnectProbeSeconds)) + let delay = max(BluetoothManager.heartbeatMinDelaySeconds, target.timeIntervalSinceNow) // Fault-listener coexistence: keep the alarm-filtered scan (C005, non-allowDuplicates — light) - // running alongside the StartDelay probe, so faults are still caught during the ~5-min heartbeat + // running alongside the StartDelay probe, so faults are still caught during the heartbeat // wait. Only a HEAVY allowDuplicates scan (monitor/beacon mode) starves the connect, so stop // just that. didConnect stops whatever scan remains for the duration of the connection. if manager.isScanning, !BluetoothManager.lowPowerMonitorEnabled { manager.stopScan() } delayedProbeInFlight = true delayedProbeIssuedAt = Date() + delayedProbeDelay = delay let pid = ProcessInfo.processInfo.processIdentifier - log.default("[delayedConnect] pid=%{public}d everFg=%{public}@ issuing connect with StartDelay=%{public}ds for %{public}@", pid, String(everForeground), delay, peripheral.identifier.uuidString) - connectionDelegate?.omnipodLogDeviceEvent("[delayedConnect] pid=\(pid) everFg=\(everForeground) issuing connect StartDelay=\(delay)s") + log.default("[delayedConnect] pid=%{public}d everFg=%{public}@ issuing connect with StartDelay=%{public}.0fs for %{public}@", pid, String(everForeground), delay, peripheral.identifier.uuidString) + connectionDelegate?.omnipodLogDeviceEvent("[delayedConnect] pid=\(pid) everFg=\(everForeground) issuing connect StartDelay=\(String(format: "%.0f", delay))s") manager.connect(peripheral, options: [CBConnectPeripheralOptionStartDelayKey: NSNumber(value: delay)]) } @@ -1073,12 +1115,14 @@ extension BluetoothManager: CBCentralManagerDelegate { let treatAsProbe = (delayedProbeInFlight && !commandConnectInFlight) if treatAsProbe { let measured = delayedProbeIssuedAt.map { String(format: "%.1f", Date().timeIntervalSince($0)) } ?? "?(restored)" + let startDelayStr = delayedProbeDelay.map { String(format: "%.0f", $0) } ?? "?" let pid = ProcessInfo.processInfo.processIdentifier - log.default("[delayedConnect] pid=%{public}d everFg=%{public}@ CONNECTED after %{public}@s (StartDelay=%{public}ds) %{public}@ — heartbeat wake", - pid, String(everForeground), measured, BluetoothManager.delayedConnectProbeSeconds, peripheral.identifier.uuidString) - connectionDelegate?.omnipodLogDeviceEvent("[delayedConnect] pid=\(pid) everFg=\(everForeground) CONNECTED after \(measured)s (StartDelay=\(BluetoothManager.delayedConnectProbeSeconds)s) — heartbeat wake") + log.default("[delayedConnect] pid=%{public}d everFg=%{public}@ CONNECTED after %{public}@s (StartDelay=%{public}@s) %{public}@ — heartbeat wake", + pid, String(everForeground), measured, startDelayStr, peripheral.identifier.uuidString) + connectionDelegate?.omnipodLogDeviceEvent("[delayedConnect] pid=\(pid) everFg=\(everForeground) CONNECTED after \(measured)s (StartDelay=\(startDelayStr)s) — heartbeat wake") delayedProbeInFlight = false delayedProbeIssuedAt = nil + delayedProbeDelay = nil // Drop the wake connection and fire the heartbeat from didDisconnect (clean idle state), so // Loop's resulting status/dose commands run via connect-on-demand rather than fighting this // transient probe link. diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index 46a3aca..84d3498 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -751,10 +751,12 @@ extension PeripheralManager { /// Connect-on-demand: after a session goes idle, if no further session is queued, disconnect /// the pod so it's left "normally disconnected" (and advertising/observable) between commands. - /// A short delay batches command bursts (status → bolus → status) into one connection. + /// The delay batches a Loop cycle's command sequence (status read → dose decision → dose enact) into + /// one connection: Loop runs its algorithm between the status read and the dose command, so the window + /// must be long enough to span that gap and avoid a reconnect mid-cycle. private func scheduleIdleDisconnectIfNeeded() { guard BluetoothManager.connectOnDemandEnabled else { return } - let idleDelay: TimeInterval = 4 + let idleDelay: TimeInterval = 15 let idleAt = idleStart queue.asyncAfter(deadline: .now() + idleDelay) { [weak self] in guard let self = self, BluetoothManager.connectOnDemandEnabled else { return } diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index ec20d14..fcf7fa5 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -315,18 +315,28 @@ public class OmniPumpManager: RileyLinkPumpManager { } public func setMustProvideBLEHeartbeat(_ mustProvideBLEHeartbeat: Bool) { + // Bridge the legacy boolean entry point through the richer request path (no reading-schedule + // detail — fall back to a default cadence in that case). + setBLEHeartbeatRequest(mustProvideBLEHeartbeat + ? PumpHeartbeatRequest(lastCGMReadingDate: nil, expectedCGMReadingInterval: .minutes(5)) + : nil) + } + + public func setBLEHeartbeatRequest(_ request: PumpHeartbeatRequest?) { // Log at the call site so we capture exactly what Loop requests and when — provideHeartbeat // isn't persisted, so reading it elsewhere can be stale relative to this call. let pid = ProcessInfo.processInfo.processIdentifier - logDeviceCommunication("[heartbeat] pid=\(pid) setMustProvideBLEHeartbeat(\(mustProvideBLEHeartbeat))", type: .connection) + let mustProvide = request != nil + let desc = request.map { "last=\($0.lastCGMReadingDate.map { String(describing: $0) } ?? "nil") interval=\(Int($0.expectedCGMReadingInterval))s" } ?? "nil" + logDeviceCommunication("[heartbeat] pid=\(pid) setBLEHeartbeatRequest(\(desc))", type: .connection) if self.state.podType.usesRileyLink { - rileyLinkDeviceProvider.timerTickEnabled = self.state.isPumpDataStale || mustProvideBLEHeartbeat + rileyLinkDeviceProvider.timerTickEnabled = self.state.isPumpDataStale || mustProvide } else { - provideHeartbeat = mustProvideBLEHeartbeat - // BLE pod: when the host needs us to provide the heartbeat (e.g. a CGM that can't), run - // the delayed-connect loop for periodic background wakes; otherwise stay disconnected + - // alarm-scan and connect on demand. - (podComms as? BlePodComms)?.setProvidesHeartbeat(mustProvideBLEHeartbeat) + provideHeartbeat = mustProvide + // BLE pod: when the host needs us to provide the heartbeat (e.g. a CGM that can't), run the + // delayed-connect loop for periodic background wakes, scheduled from the CGM reading time; + // otherwise stay disconnected + alarm-scan and connect on demand. + (podComms as? BlePodComms)?.setHeartbeatRequest(request) } } From 381a6b55fbc0d9a23b6b9115ace5242fabd36296 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 17 Jul 2026 12:37:11 -0500 Subject: [PATCH 83/92] Fix StartDelay heartbeat probe: integer delay, failure backoff, foreground-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes to the pump-provided BLE heartbeat (StartDelay probe): - Integer StartDelay. CBConnectPeripheralOptionStartDelayKey requires a whole number of seconds; the computed delay was a fractional Double, so every probe connect was rejected with CBErrorDomain Code=1 'One or more parameters were invalid'. Round to Int seconds. - Failure backoff. didFailToConnect re-armed the probe synchronously, so the invalid-parameter rejection spun into a tight connect/fail loop (~5k/sec). Re-arm after a backoff and re-check state, so no connect failure can tight-loop. - Foreground-only. StartDelay is a background-only mechanism — iOS ignores the delay while foreground, so the probe connected instantly, fired, disconnected, re-armed and churned. Gate delayedConnectProbeActive on !isAppForeground; the keep-alive connection owns the link while foreground. An overdue target still retries at the 60s floor (promptly catches a network CGM value that arrives a little late). --- OmnipodKit/Bluetooth/BluetoothManager.swift | 48 +++++++++++++++------ 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index fa2f6ab..bbe0cab 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -252,6 +252,12 @@ class BluetoothManager: NSObject { (UserDefaults.standard.object(forKey: "OmnipodKit.heartbeatMinDelaySeconds") as? Double) ?? 60 } + /// Backoff (seconds) before re-arming the StartDelay probe after a connect FAILURE, so a + /// persistently-failing connect can't re-issue in a tight loop. + static var heartbeatFailureBackoffSeconds: TimeInterval { + (UserDefaults.standard.object(forKey: "OmnipodKit.heartbeatFailureBackoffSeconds") as? Double) ?? 30 + } + /// Candidate DASH alarm-state service UUIDs to filter on in low-power mode. /// - `C005`: CONFIRMED 16-bit alarm 2nd-UUID on this pod (expiration reminder). Extend as more /// alert/alarm types are captured. @@ -316,12 +322,14 @@ class BluetoothManager: NSObject { /// demand. managerQueue-isolated. private var heartbeatEnabled = false - /// The delayed-connect (StartDelay) heartbeat probe runs exactly when Loop asks the pump to provide - /// the BLE heartbeat — i.e. `heartbeatEnabled`, set via PumpManager.setBLEHeartbeatRequest. No - /// other gate: whenever the host needs a pump-provided heartbeat, the connect-delay probe is active. - /// Isolated to managerQueue (all probe call sites run there). + /// The delayed-connect (StartDelay) heartbeat probe runs when Loop asks the pump to provide the BLE + /// heartbeat (`heartbeatEnabled`, via PumpManager.setBLEHeartbeatRequest) AND the app is backgrounded. + /// CBConnectPeripheralOptionStartDelayKey is a background-only mechanism — iOS ignores the delay while + /// the app is foreground, so a foreground probe connects immediately, is treated as a wake, disconnects, + /// re-arms, and churns. While foreground the keep-alive connection already owns the link; heartbeat + /// wakes only matter for a suspended app. Isolated to managerQueue (all probe call sites run there). private var delayedConnectProbeActive: Bool { - heartbeatEnabled + heartbeatEnabled && !isAppForeground } /// Enable/disable and schedule the pump-provided heartbeat (delayed-connect loop). Driven by @@ -384,9 +392,15 @@ class BluetoothManager: NSObject { peripheral.state == .disconnected else { return } // Compute the StartDelay so the wake lands no earlier than the next-heartbeat target // (lastCGMReading + expectedInterval + buffer). Floored so an overdue target can't collapse to a - // near-zero delay. Falls back to the fixed probe interval when Loop hasn't supplied a schedule. + // near-zero delay — an overdue target (missed/late reading) retries at the floor, which for a + // network CGM promptly catches a value that arrives a little late. Falls back to the fixed probe + // interval when Loop hasn't supplied a schedule. let target = heartbeatTargetDate ?? Date().addingTimeInterval(TimeInterval(BluetoothManager.delayedConnectProbeSeconds)) - let delay = max(BluetoothManager.heartbeatMinDelaySeconds, target.timeIntervalSinceNow) + // CBConnectPeripheralOptionStartDelayKey requires an INTEGER number of seconds — a fractional + // NSNumber(double) is rejected with CBErrorDomain Code=1 "One or more parameters were invalid", + // which (combined with the failure re-arm) produced a tight connect/fail loop. Round to whole + // seconds, floored so an overdue target can't collapse to a near-zero delay. + let delaySeconds = max(Int(BluetoothManager.heartbeatMinDelaySeconds), Int(target.timeIntervalSinceNow.rounded())) // Fault-listener coexistence: keep the alarm-filtered scan (C005, non-allowDuplicates — light) // running alongside the StartDelay probe, so faults are still caught during the heartbeat // wait. Only a HEAVY allowDuplicates scan (monitor/beacon mode) starves the connect, so stop @@ -394,11 +408,11 @@ class BluetoothManager: NSObject { if manager.isScanning, !BluetoothManager.lowPowerMonitorEnabled { manager.stopScan() } delayedProbeInFlight = true delayedProbeIssuedAt = Date() - delayedProbeDelay = delay + delayedProbeDelay = TimeInterval(delaySeconds) let pid = ProcessInfo.processInfo.processIdentifier - log.default("[delayedConnect] pid=%{public}d everFg=%{public}@ issuing connect with StartDelay=%{public}.0fs for %{public}@", pid, String(everForeground), delay, peripheral.identifier.uuidString) - connectionDelegate?.omnipodLogDeviceEvent("[delayedConnect] pid=\(pid) everFg=\(everForeground) issuing connect StartDelay=\(String(format: "%.0f", delay))s") - manager.connect(peripheral, options: [CBConnectPeripheralOptionStartDelayKey: NSNumber(value: delay)]) + log.default("[delayedConnect] pid=%{public}d everFg=%{public}@ issuing connect with StartDelay=%{public}ds for %{public}@", pid, String(everForeground), delaySeconds, peripheral.identifier.uuidString) + connectionDelegate?.omnipodLogDeviceEvent("[delayedConnect] pid=\(pid) everFg=\(everForeground) issuing connect StartDelay=\(delaySeconds)s") + manager.connect(peripheral, options: [CBConnectPeripheralOptionStartDelayKey: NSNumber(value: delaySeconds)]) } /// The keep-connected auto-reconnect. Suppressed in connect-on-demand mode, where the pod is @@ -1207,7 +1221,17 @@ extension BluetoothManager: CBCentralManagerDelegate { delayedProbeInFlight = false resumeScanIfNeeded() // keep the fault-listener alarm scan running while idle if delayedConnectProbeActive && !commandConnectInFlight && autoConnectIDs.contains(peripheral.identifier.uuidString) { - issueDelayedConnectProbe(peripheral) // re-arm so a failed connect doesn't stall the loop + // Re-arm AFTER a backoff — a synchronously-failing connect (bad parameters, radio off, pod + // gone) must never re-issue at CPU speed. Re-fetch and re-check state after the delay. + let id = peripheral.identifier.uuidString + managerQueue.asyncAfter(deadline: .now() + BluetoothManager.heartbeatFailureBackoffSeconds) { [weak self] in + guard let self = self, + self.delayedConnectProbeActive, !self.commandConnectInFlight, !self.delayedProbeInFlight, + self.autoConnectIDs.contains(id), + let p = self.devices.first(where: { $0.manager.peripheral.identifier.uuidString == id })?.manager.peripheral, + p.state == .disconnected else { return } + self.issueDelayedConnectProbe(p) + } } } } From b1ecb278fa97be2c41712eade5ffdfa63230af07 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 17 Jul 2026 15:32:30 -0500 Subject: [PATCH 84/92] Add O5 advertising / fault-signalling field findings Documents the O5 advert structure, the induced-occlusion capture (service-UUID suffix 00->02 with fault code 0x14 in mfg, persisted ~10min while healthy 00 ceased), the RE engineer's 4-state destinationStatus model (02 = coarse faulted/attention bucket, not fault-type-specific), the safety analysis (controllerId from shared cert pool -> filter not pod-unique, but no false alarm via own-pod status read), and the open questions before implementing an O5 connectionless fault scan (what are suffixes 01/03; is any non-00 state persistent). --- O5_ADVERTISING_FINDINGS.md | 94 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 O5_ADVERTISING_FINDINGS.md diff --git a/O5_ADVERTISING_FINDINGS.md b/O5_ADVERTISING_FINDINGS.md new file mode 100644 index 0000000..214c0d6 --- /dev/null +++ b/O5_ADVERTISING_FINDINGS.md @@ -0,0 +1,94 @@ +# O5 (Omnipod 5) Advertisement / Fault Signalling — Field Findings + +Companion to `DASH_BEACON_FINDINGS.md`, for the **O5** pod. From live capture on a real O5 pod +(peripheral `636C6109…`, controllerId `0x002A1C6C`), including a **deliberately induced occlusion +fault** (cannula clamped + repeated large boluses; pod sacrificed). + +## Advertisement structure (healthy) + +O5 advertises a **single 128-bit service UUID** plus 7 bytes of manufacturer data: + +``` +svcUUIDs = [ CE1F923D-C539-48EA-7300-0A ] +mfg = 60 03 00 +``` + +- **Service UUID** = `o5ServiceAdvertisementUUID(controllerId)` — a fixed prefix + the 32-bit + `controllerId` (a.k.a. pdmId, = `myId`) + a **1-byte status suffix**. + - Healthy value observed for 5 days straight: `…0A002A1C6C`**`00`** (suffix `00`). + - Pre-pairing placeholder: `…0AFFFFFFFE00`. +- **Manufacturer data** (`60 03 00 …`): + - byte3 `` — a ~2-minute "time since last connect" counter; increments ~every 2 min, + **resets to 00 on each connect**. Not a fault signal. + - byte4 `` — `0x10` healthy. + - byte5 `` — `0x00` healthy. + - byte6 `` — alert bitmask (`0x00` = none), same slot scheme as DASH. + +## The occlusion fault (measured) + +At the fault, **both** the UUID suffix and the manufacturer data changed, and the change **persisted**: + +``` +HEALTHY svcUUIDs=[CE1F923D-…-0A002A1C6C00] mfg=60 03 00 10 00 00 +FAULTED svcUUIDs=[CE1F923D-…-0A002A1C6C02] mfg=60 03 00 18 14 00 + ↑↑ ↑↑ ↑↑ +``` + +- **Service UUID suffix `00` → `02`.** +- **mfg byte4 `10` → `18`** (a "faulted" flag), **byte5 `00` → `14`** (`0x14` = occlusion — the same + fault-code value DASH uses). +- The counter (byte3) kept incrementing/resetting normally. + +**Timing evidence (this is the important part — it's a real state change, not a blip):** +- `…6C02` was **never advertised once in the 5 days before the fault**. +- Healthy `…6C00` **stopped** at the fault; **zero** `…6C00` frames appeared afterward. +- `…6C02` **persisted ~10 minutes** across 6 distinct frames (until the pod was deactivated), with + `faultCode 0x14` stable in every one. + +## RE engineer static analysis (TWISDK) — refines the model + +- The UUID suffix is a **4-state space: `00, 01, 02, 03`**. TWISDK has literal templates + `CE1F923D-C539-48EA-7300-0A%@00` through `…%@03`, and a real scan request lists all four. +- The parsed field is named generically **`destinationStatus`**, with **separate** `alarmCode` / + `alertCode` fields. The connected-status fault taxonomy (occlusion subtypes, etc.) lives in the + PodSDK status/alarm models, **not** in the advert. +- Conclusion: `02` is a **coarse advertised "pod attention/status" bucket, NOT occlusion-specific**. + There is no static evidence for a suffix → fault-type mapping. Our `00 = normal, 02 = faulted` + observation is consistent with this, but the **exact fault type must be resolved from the + connected status**, not the UUID byte. + +## Implications for connectionless (deep-idle) O5 fault detection + +Feasible, analogous to the DASH C00A scan: filter the idle scan on the **non-normal suffix(es)** +(`CE1F923D-…-0A02`, built from our known controllerId) so a fault is a genuinely NEW +discovery that wakes a suspended app. The advert change is only a **"go look" wake trigger**; the +actual fault/alert is always read from the connected pod status. + +### Open questions (before finalizing the filter) +1. **What are suffixes `01` and `03`?** Unknown (only `00` and `02` observed). Likely other + attention/alert states. +2. **Is any non-`00` suffix *persistent*?** ← the one that matters. Per the DASH lesson, a + perpetually-advertised state (like DASH `C005` alert-configured) in the filter makes iOS + *coalesce* the fault re-discovery → slow wakes. Our pod stayed at `00` for 5 days even with + reminders configured (encouraging — suggests O5 holds `00` until an actual event), but this is + **not yet confirmed**. Capturing an O5 **alert** (fire an expiration-reminder / low-reservoir + alert on a normal pod) would show which suffix it uses and whether it sticks. + +## Safety (does the scan risk another user's pod?) + +- **Filter is not pod-unique.** The O5 `controllerId` is drawn from `O5CertificateStore` + (`O5RegistrationData.allValues.randomElement()`); **compiled certs are shared across an app + build**, so controllerIds can collide across users (the source explicitly notes the value "can't + be … semi-unique across for all users"). So a nearby stranger's faulted pod *can* match our + filter and wake us. +- **But it cannot false-alarm or cross-command.** A wake triggers a connect to **our own** pod (by + its unique `bleIdentifier`) + a status read; the alarm is issued only if **our** pod is faulted. + Commands are encrypted/signed with our pod's session keys (LTK), which a foreign pod lacks. +- **Mitigation for the residual spurious-wake cost:** gate the fault handler on + `autoConnectIDs.contains(peripheral.identifier)` (own-pod BLE identity) so foreign adverts are + dropped without a connect. (The current DASH path doesn't do this and should get the same guard.) + +## Status +The O5 connectionless fault scan is **not yet implemented** — this is the research backing it. O5 +currently relies on the StartDelay heartbeat + connect-on-demand to catch a fault on the next status +read (same as DASH before its C00A scan). From 9c1481d4cf7e808e735aa58da45e2f54c8b22873 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 17 Jul 2026 15:32:30 -0500 Subject: [PATCH 85/92] Remove BLE capture / periodic-status / test-alert debug scaffolding Strip the O5-investigation debug tooling now that the connection-policy and heartbeat work is validated, leaving the production connect-on-demand + StartDelay heartbeat + DASH C00A fault scan intact: - Remove bleCaptureEnabled (wildcard advert-capture scan + keep-alive override) and captureAllCharsEnabled (all-characteristic capture). appIsForeground is now just the real foreground state; the idle scan is the production C00A (DASH) / monitor (O5) scan. - Remove periodicStatusEnabled + configurePeriodicStatus (the SN2.0= periodic-push experiment). - Remove the [capture] characteristic/value logging and the logCaptureEvent delegate hop. - Remove the debug 'Trigger Test Alert' diagnostics button + triggerTestAlert. Kept: [ADV] distinct-advert device logging (field diagnostics), DASH C00A connectionless fault detection, and all connection-policy/heartbeat behavior. --- OmnipodKit/Bluetooth/BlePodComms.swift | 71 ------------------- OmnipodKit/Bluetooth/BluetoothManager.swift | 30 ++------ OmnipodKit/Bluetooth/PeripheralManager.swift | 50 ------------- OmnipodKit/PumpManager/OmniPumpManager.swift | 32 --------- .../Views/PodDiagnosticsView.swift | 11 --- 5 files changed, 4 insertions(+), 190 deletions(-) diff --git a/OmnipodKit/Bluetooth/BlePodComms.swift b/OmnipodKit/Bluetooth/BlePodComms.swift index 8ffdc1a..8bca271 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -352,69 +352,6 @@ class BlePodComms: PodComms { // The podState's bleMessageTransportState will be updated when the above defer block is executed. } - // MARK: - Periodic-status registration (arms the pod to push) - - /// BEST-GUESS, UNCONFIRMED envelope. Registers a periodic status push so the pod ORIGINATES - /// status-response frames on a schedule (caught by the unsolicited listener), with fault/alert - /// flags riding inside — "register once, don't poll". Called post-session-establishment while - /// holding podStateLock. Non-fatal: a rejection is logged so we can iterate the envelope. - /// - /// The exact wire form is unconfirmed. Current guesses (each a labeled knob to iterate from - /// field logs): command `SN0.0=,GN0.0` (feature "N0", attr "0", ASCII decimal - /// seconds), response prefix `N0.0=`. If REJECTED, try: a different feature/attr split, binary - /// vs ASCII seconds, or the standard `S0.0=` envelope. - private func configurePeriodicStatus() { - guard BluetoothManager.periodicStatusEnabled else { return } - guard let manager = manager, podState != nil else { return } - // Only on a healthy, fully-set-up pod. Never during pairing/activation, and never - // on a faulted pod — otherwise this fires on every reconnect of a screaming pod and - // adds SN0.0= writes to a connect/disconnect loop. - guard podState?.isSetupComplete == true else { - log.default("[periodic] skip registration: pod setup not complete") - omnipodLogDeviceEvent("[periodic] skip: pod setup not complete") - return - } - guard podState?.fault == nil else { - log.default("[periodic] skip registration: pod is faulted") - omnipodLogDeviceEvent("[periodic] skip: pod is faulted") - return - } - let intervalSeconds = 30 // ~30s so pushes arrive quickly while testing (RE-confirmed cadences are 300s Auto / 60s fault) - - let transport = BlePodMessageTransport(manager: manager, myId: myId, podId: podId, state: podState!.bleMessageTransportState, signingKey: podState?.signingKey) - transport.messageLogger = messageLogger - defer { - // Persist the sequence advance from the send so normal comms stay in sync. - podState!.bleMessageTransportState = BleMessageTransportState(ck: transport.ck, noncePrefix: transport.noncePrefix, msgSeq: transport.msgSeq, nonceSeq: transport.nonceSeq, messageNumber: transport.messageNumber) - } - - // Arm periodic Status via the AID Set command SN2.0= (RE engineer, 2026-07-13): - // S = AID Set, N2 = periodic namespace N + command token 2 (= Status), .0 = attribute 0, - // = = decimal-ASCII period. (Our earlier SN0.0= used the undefined command token 0.) - // SET-only, SLPE-wrapped, sent through the encrypted transport (Type-4 signed for O5). - // FIRE-AND-FORGET — the pod sends NO synchronous reply; success = the write is ACK'd, and the real - // confirmation is the pod's first unsolicited PUSH ~intervalSeconds later. - let keys = ["SN2.0="] - let payloads = [Data(String(intervalSeconds).utf8)] - let wrapped = StringLengthPrefixEncoding.formatKeys(keys: keys, payloads: payloads) - log.default("[periodic] pre-register transport state: nonceSeq=%{public}d msgSeq=%{public}d messageNumber=%{public}d eapSeq=%{public}d bleId=%{public}@", - transport.nonceSeq, transport.msgSeq, transport.messageNumber, transport.eapSeq, podState?.bleIdentifier ?? "?") - log.default("[periodic] register (fire-and-forget SLPE): keys=%{public}@ seconds=%{public}d wrappedHex=%{public}@ — success=write-ACK; watch [unsolicited] for a push in ~%{public}ds", - keys.joined(), intervalSeconds, wrapped.hexadecimalString, intervalSeconds) - omnipodLogDeviceEvent("[periodic] arming SN2.0=\(intervalSeconds) (podType=\(podType.isO5 ? "O5" : "DASH")) wrappedHex=\(wrapped.hexadecimalString)") - do { - try transport.sendSlpeCommandFireAndForget(keys: keys, payloads: payloads) - log.default("[periodic] register write ACK'd — pod received SN2.0=%{public}d. Expecting an unsolicited push in ~%{public}ds (the push, not a reply, is the success signal).", - intervalSeconds, intervalSeconds) - omnipodLogDeviceEvent("[periodic] arm write ACK'd (SN2.0=\(intervalSeconds)) — watch for a push in ~\(intervalSeconds)s") - } catch { - log.error("[periodic] register send NOT ACK'd: %{public}@", String(describing: error)) - omnipodLogDeviceEvent("[periodic] arm write NOT ACK'd: \(String(describing: error))") - } - log.default("[periodic] post-register transport state: nonceSeq=%{public}d msgSeq=%{public}d messageNumber=%{public}d", - transport.nonceSeq, transport.msgSeq, transport.messageNumber) - } - // MARK: - O5 Specific AID Setup commands /// Sends the O5-specific AID setup commands between GetStatus and SetupPod. @@ -848,10 +785,6 @@ extension BlePodComms: PeripheralManagerDelegate { try manager.enableNotifications() // Seemingly this cannot be done before the hello command, or the pod disconnects try establishNewSession() needsSessionEstablishment = false - // Re-enabled with the corrected SLPE encoding + a non-disconnecting read. The - // earlier plain-ASCII attempt sent an unparseable frame → pod silent → disconnect - // loop. If SLPE is still wrong the read no longer drops the pod (logs + continues). - configurePeriodicStatus() manager.unsolicitedListenerArmed = true // encrypted session ready; safe to observe pod-initiated transfers delegate?.podCommsDidEstablishSession(self) } catch { @@ -878,10 +811,6 @@ extension BlePodComms: PodCommsSessionDelegate { // MARK: - Unsolicited (pod-initiated) fault decrypt + logging (opt-in diagnostic) extension BlePodComms { - func peripheralManager(_ manager: PeripheralManager, logCaptureEvent message: String) { - omnipodLogDeviceEvent(message) - } - func peripheralManager(_ manager: PeripheralManager, didReceiveUnsolicitedMessagePacket packet: MessagePacket) { podStateLock.lock() let mtsSnapshot = podState?.bleMessageTransportState diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index bbe0cab..823565a 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -215,22 +215,6 @@ class BluetoothManager: NSObject { UserDefaults.standard.object(forKey: "OmnipodKit.scanningEnabled") as? Bool ?? true } - /// FULL BLE CAPTURE (revert before PR): high-fidelity RE capture — while idle, run a WILDCARD advert - /// scan (matches the pod in ANY state, incl. O5's CE1F923D UUID; does NOT assume DASH C00A) with - /// keep-alive OFF so the pod stays advertising; on connect, discover ALL services + characteristics, - /// subscribe to EVERY notifiable/indicatable one, and device-log every advert and every value update. - /// Default ON for the O5-investigation build. - static var bleCaptureEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.bleCaptureEnabled") as? Bool ?? false - } - - /// PERIODIC-STATUS TEST (revert before PR): after session establishment, arm the pod's connected-state - /// periodic-status nudge (SN0.0=) and STAY CONNECTED (force keep-alive on) so we can observe - /// the pod-initiated CMD indication push on a timer. Every value update is device-logged. Default ON - /// for this test build. - static var periodicStatusEnabled: Bool { - UserDefaults.standard.object(forKey: "OmnipodKit.periodicStatusEnabled") as? Bool ?? false - } /// Fallback start delay (seconds) for the delayed-connect probe when Loop hasn't supplied a heartbeat /// schedule (no `heartbeatTargetDate`). Normally the delay is computed from the CGM reading schedule — @@ -308,7 +292,7 @@ class BluetoothManager: NSObject { /// live and in-app commands are instant. On background we disconnect and resume the heartbeat probe. private var isAppForeground = false /// Cross-queue read for PeripheralManager's idle-disconnect (benign bool race, like everForeground). - var appIsForeground: Bool { isAppForeground && !BluetoothManager.bleCaptureEnabled } + var appIsForeground: Bool { isAppForeground } /// True once this PROCESS has ever been foregrounded. A [delayedConnect] with everFg=false means /// iOS ran this process entirely in the background — proof of a background wake/relaunch the user @@ -781,12 +765,7 @@ class BluetoothManager: NSObject { log.default("[connectOnDemand] scanning disabled — not starting a scan (scan-free connect mode)") return } - if BluetoothManager.bleCaptureEnabled { - // Full-capture: wildcard scan (matches the pod in any state, incl. O5's CE1F923D UUID — - // no DASH-C00A assumption) + allowDuplicates so we see the advert cadence and any state flip. - services = nil - options = [CBCentralManagerScanOptionAllowDuplicatesKey: true] - } else if BluetoothManager.lowPowerMonitorEnabled && podType.isDash { + if BluetoothManager.lowPowerMonitorEnabled && podType.isDash { // Low-power fault-watch (DASH only): wake on a fault-state advertisement — filter on the alarm // UUID(s) (C00A), no allowDuplicates. C00A is DASH-specific, so never used for O5. services = BluetoothManager.alarmServiceUUIDs @@ -1022,7 +1001,7 @@ extension BluetoothManager: CBCentralManagerDelegate { // the input to the connectionless fault-detection path. Captures every field. let advSvcUUIDs = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]) ?? [] let isPodFrame = autoConnectIDs.contains(peripheral.identifier.uuidString) || PodAdvertisement(advertisementData, podType: podType) != nil - if BluetoothManager.advertisementMonitorEnabled || BluetoothManager.bleCaptureEnabled, isPodFrame { + if BluetoothManager.advertisementMonitorEnabled, isPodFrame { let svcUUIDs = advSvcUUIDs.map { $0.uuidString }.joined(separator: ",") let mfg = (advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data)?.hexadecimalString ?? "-" let svcData = (advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data])? @@ -1049,8 +1028,7 @@ extension BluetoothManager: CBCentralManagerDelegate { } } } else if let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data, - BluetoothManager.advertisementMonitorEnabled, !BluetoothManager.bleCaptureEnabled { - // Suppressed during wildcard capture — this would fire for every nearby BLE device. + BluetoothManager.advertisementMonitorEnabled { log.default("[SCAN] ManufacturerData: %{public}@ (%{public}d bytes)", mfgData.hexadecimalString, mfgData.count) } diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index 84d3498..7566f30 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -133,14 +133,6 @@ protocol PeripheralManagerDelegate: AnyObject { /// in flight. The implementer (BlePodComms) holds the session keys and decrypts + /// logs it. Default is a no-op. Gated by `PeripheralManager.unsolicitedFaultListenerEnabled`. func peripheralManager(_ manager: PeripheralManager, didReceiveUnsolicitedMessagePacket packet: MessagePacket) - - /// Route a device-log line (Issue Report) — used by the full BLE capture to record discovered - /// characteristics and every value update. Default no-op. - func peripheralManager(_ manager: PeripheralManager, logCaptureEvent message: String) -} - -extension PeripheralManagerDelegate { - func peripheralManager(_ manager: PeripheralManager, logCaptureEvent message: String) { } } extension PeripheralManagerDelegate { @@ -252,41 +244,6 @@ extension PeripheralManager { try setNotifyValue(true, for: characteristic, timeout: discoveryTimeout) } } - - if BluetoothManager.bleCaptureEnabled { - try? captureAllServicesAndCharacteristics(timeout: discoveryTimeout) - } - } - - /// FULL BLE CAPTURE (revert before PR): discover ALL services + characteristics and subscribe to every - /// notifiable/indicatable one, logging each — so unsolicited pushes anywhere on the pod are captured in - /// didUpdateValueFor. Best-effort; errors here must never break a real session. - private func captureAllServicesAndCharacteristics(timeout: TimeInterval) throws { - // NEVER subscribe to the pod's own command/data/heartbeat characteristics here — the session - // flow (enableNotifications, during the O5 AID handshake) owns their notify timing, and enabling - // them early breaks O5's message sequencing. We only subscribe to UNEXPECTED notifiable chars. - let profileChars = Set([profile.commandCharacteristicUUID, profile.dataCharacteristicUUID, - profile.heartbeatCharacteristicUUID].compactMap { $0 }) - try runCommand(timeout: timeout) { - addCondition(.discoverServices) - peripheral.discoverServices(nil) // nil = all services - } - for service in peripheral.services ?? [] { - try runCommand(timeout: timeout) { - addCondition(.discoverCharacteristicsForService(serviceUUID: service.uuid)) - peripheral.discoverCharacteristics(nil, for: service) // nil = all characteristics - } - for ch in service.characteristics ?? [] { - let p = ch.properties - let flags = [p.contains(.notify) ? "notify" : nil, p.contains(.indicate) ? "indicate" : nil, - p.contains(.read) ? "read" : nil, p.contains(.write) ? "write" : nil, - p.contains(.writeWithoutResponse) ? "writeNR" : nil].compactMap { $0 }.joined(separator: ",") - delegate?.peripheralManager(self, logCaptureEvent: "[capture] char service=\(service.uuid.uuidString) char=\(ch.uuid.uuidString) props=[\(flags)]") - if (p.contains(.notify) || p.contains(.indicate)), !ch.isNotifying, !profileChars.contains(ch.uuid) { - try? setNotifyValue(true, for: ch, timeout: timeout) - } - } - } } } @@ -574,13 +531,6 @@ extension PeripheralManager: CBPeripheralDelegate { } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - if BluetoothManager.bleCaptureEnabled || BluetoothManager.periodicStatusEnabled { - // Full capture: log EVERY value update on EVERY subscribed characteristic (incl. ones outside - // the pod profile that we subscribed to below), so any unsolicited push — e.g. the pod's - // periodic-status CMD nudge — lands in the log. - let hex = characteristic.value?.hexadecimalString ?? "-" - delegate?.peripheralManager(self, logCaptureEvent: "[capture] notify char=\(characteristic.uuid.uuidString) len=\(characteristic.value?.count ?? 0) value=\(hex)") - } commandLock.lock() if let macro = configuration.valueUpdateMacros[characteristic.uuid] { diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index fcf7fa5..786b4a3 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -1844,38 +1844,6 @@ extension OmniPumpManager { } } - /// CAPTURE (revert before PR): schedule a real non-fault pod alert (expiration reminder) to fire - /// ~60s from now so we can capture the pod's alarm-state advertisement on command. Works for DASH - /// and O5 (configureAlerts is pod-type-agnostic via the session). Non-destructive — acknowledge the - /// alert away afterward. - func triggerTestAlert() async throws { - guard self.hasActivePod else { - throw OmniPumpManagerError.noPodPaired - } - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - self.runSession(withName: "Trigger Test Alert") { (result) in - switch result { - case .success(let session): - self.handleSilencePodEnd(session: session) - let podTime = self.podTime - let alertPodTime = podTime + TimeInterval(seconds: 60) // fire ~60s from now (mirrors updateExpirationReminder math) - let testAlert = PodAlert.expirationReminder(offset: podTime, absAlertTime: alertPodTime, silent: false) - do { - let beepBlock = self.beepMessageBlock(beepType: .beep) - let _ = try session.configureAlerts([testAlert], beepBlock: beepBlock) - self.log.default("[testAlert] scheduled expirationReminder to fire in ~60s (podTime=%{public}@, alertPodTime=%{public}@)", podTime.timeIntervalStr, alertPodTime.timeIntervalStr) - self.logDeviceCommunication("[testAlert] scheduled expirationReminder to fire in ~60s (alertPodTime=\(alertPodTime.timeIntervalStr))", type: .connection) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - // Called on the main thread. // The UI is responsible for serializing calls to this method; // it does not handle concurrent calls. diff --git a/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift b/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift index 38fbb0f..abd3516 100644 --- a/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PodDiagnosticsView.swift @@ -21,7 +21,6 @@ protocol DiagnosticCommands { func readTriggeredAlerts() async throws -> String func getDetailedStatus() async throws -> DetailedStatus func pumpManagerDetails() -> String - func triggerTestAlert() async throws } struct PodDiagnosticsView: View { @@ -48,16 +47,6 @@ struct PodDiagnosticsView: View { } .disabled(!podOk) - // CAPTURE (revert before PR): fire a real non-fault alert ~60s out so we can capture the - // pod's alarm-state advertisement (DASH + O5) on command, non-destructively. - Button(action: { - Task { try? await diagnosticCommands.triggerTestAlert() } - }) { - Text("Trigger Test Alert (debug, fires in ~60s)") - .foregroundColor(Color.primary) - } - .disabled(!podOk) - NavigationLink(destination: ReadPodInfoView( title: LocalizedString("Read Pulse Log", comment: "Text for read pulse log title"), actionString: LocalizedString("Reading Pulse Log...", comment: "Text for read pulse log action"), From 798c78832f0a14a1fdc8bc70d7758b4f691a3bd2 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 18 Jul 2026 14:17:53 -0500 Subject: [PATCH 86/92] Gate DASH connectionless fault detection to our own pod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The C00A fault-scan filter is generic (every DASH pod advertises C00A on fault), so a nearby stranger's faulted pod matches it and wakes us. Detection was gated on isPodFrame (autoConnectIDs OR any pod-shaped advert), so it also ran detectPodAlertStatus / fresh-connect / the StartDelay probe against foreign pods. No false alarm resulted (the alarm comes from a connected read of our own pod), but a foreign advert could trigger a spurious connect and briefly set alarmScanSuppressed, quieting our own fault scan. Gate that block on isOwnPod (autoConnectIDs.contains(peripheral) — our unique BLE identity) so only our pod drives detection/connect/probe. Advert [ADV] logging stays on any pod-shaped frame (diagnostics + pairing). Sets the pattern for the future O5 scan. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 823565a..5155472 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -1001,6 +1001,11 @@ extension BluetoothManager: CBCentralManagerDelegate { // the input to the connectionless fault-detection path. Captures every field. let advSvcUUIDs = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]) ?? [] let isPodFrame = autoConnectIDs.contains(peripheral.identifier.uuidString) || PodAdvertisement(advertisementData, podType: podType) != nil + // Only OUR paired pod (unique BLE identity) may drive fault detection / connect / probe. The + // C00A fault-scan filter is generic (any DASH pod's fault matches), so a nearby stranger's + // faulted pod can wake us — we must NOT act on it (no false alarm, and no foreign connect or + // scan-suppression). Advert LOGGING below stays on any pod-shaped frame (diagnostics + pairing). + let isOwnPod = autoConnectIDs.contains(peripheral.identifier.uuidString) if BluetoothManager.advertisementMonitorEnabled, isPodFrame { let svcUUIDs = advSvcUUIDs.map { $0.uuidString }.joined(separator: ",") let mfg = (advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data)?.hexadecimalString ?? "-" @@ -1033,8 +1038,9 @@ extension BluetoothManager: CBCentralManagerDelegate { } // Connectionless alarm decode is DASH-specific (parses the DASH iBeacon status word). O5 encodes - // state differently (see the capture) — never run the DASH decode against an O5 advert. - if isPodFrame && podType.isDash { + // state differently (see the capture) — never run the DASH decode against an O5 advert. Gated on + // isOwnPod so a foreign pod that matched the generic C00A filter can't drive detection/connect/probe. + if isOwnPod && podType.isDash { detectPodAlertStatus(peripheral: peripheral, advertisementData: advertisementData) // Fresh-discovery connect: we just heard the pod — stop scanning and connect NOW on this // fresh advertisement (fast) instead of waiting out iOS's cold reacquisition (~16s). From fdafb4a0115c5f2e2eb6be8583d8258d124d087c Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 18 Jul 2026 17:06:04 -0500 Subject: [PATCH 87/92] Respect Pod Keep Alive: hold the pod connected instead of disconnecting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect-on-demand's disconnects (15s idle-disconnect + disconnect on app background) ignored the Pod Keep Alive setting, defeating it: those modes exist for iPhone 16/17e + InPlay (Atlas) DASH pods where a disconnect->reconnect is unreliable, and issue a status refresh at ~2:40 to hold the pod's 3-minute connection window. Our 15s disconnect fired ~2.5 min before that refresh, and the background disconnect killed silentTune/rileyLink outright. Add BluetoothManager.shouldHoldConnection = isAppForeground OR (DASH + a background Pod Keep Alive mode). Gate the idle-disconnect, the background disconnect (now held + reconnected if dropped), the reconnect-after-drop, the heartbeat-disable teardown, and the StartDelay probe on it. When Pod Keep Alive is .disabled (default) or .whenOpen, shouldHoldConnection == isAppForeground — i.e. NO change from the validated connect-on-demand behavior; only silentTune/rileyLink now stay connected in the background. Adds PodKeepAlive.keepsPodConnectedInBackground. Not device-tested (no InPlay pods available). --- OmnipodKit/Bluetooth/BluetoothManager.swift | 55 ++++++++++++++----- OmnipodKit/Bluetooth/PeripheralManager.swift | 11 ++-- .../Views/PodKeepAliveView.swift | 14 +++++ 3 files changed, 61 insertions(+), 19 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 5155472..ea5378d 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -294,6 +294,19 @@ class BluetoothManager: NSObject { /// Cross-queue read for PeripheralManager's idle-disconnect (benign bool race, like everForeground). var appIsForeground: Bool { isAppForeground } + /// True when the pod should be HELD connected rather than idle/background-disconnected — the gate that + /// suppresses connect-on-demand's disconnects. True while the app is foregrounded (foreground + /// keep-alive), OR whenever a *background* Pod Keep Alive mode (silentTune / rileyLink — DASH only) is + /// selected. Those modes exist for iPhone 16/17e + InPlay (Atlas) DASH pods where a disconnect→reconnect + /// is unreliable, so the pod must stay connected and the keep-alive's periodic status refresh maintains + /// the link. When Pod Keep Alive is `.disabled` (the default) OR `.whenOpen`, this collapses to exactly + /// `isAppForeground` — i.e. no change from the validated connect-on-demand behavior. Read from + /// managerQueue and cross-queue by PeripheralManager (benign bool race, like appIsForeground). + var shouldHoldConnection: Bool { + if isAppForeground { return true } + return podType.isDash && Storage.shared.podKeepAlive.value.keepsPodConnectedInBackground + } + /// True once this PROCESS has ever been foregrounded. A [delayedConnect] with everFg=false means /// iOS ran this process entirely in the background — proof of a background wake/relaunch the user /// did NOT initiate (a manual open would have foregrounded it). Set on the main queue via a @@ -307,13 +320,14 @@ class BluetoothManager: NSObject { private var heartbeatEnabled = false /// The delayed-connect (StartDelay) heartbeat probe runs when Loop asks the pump to provide the BLE - /// heartbeat (`heartbeatEnabled`, via PumpManager.setBLEHeartbeatRequest) AND the app is backgrounded. - /// CBConnectPeripheralOptionStartDelayKey is a background-only mechanism — iOS ignores the delay while - /// the app is foreground, so a foreground probe connects immediately, is treated as a wake, disconnects, - /// re-arms, and churns. While foreground the keep-alive connection already owns the link; heartbeat - /// wakes only matter for a suspended app. Isolated to managerQueue (all probe call sites run there). + /// heartbeat (`heartbeatEnabled`, via PumpManager.setBLEHeartbeatRequest) AND we are NOT holding the pod + /// connected. CBConnectPeripheralOptionStartDelayKey is a background-only mechanism — iOS ignores the + /// delay while the app is foreground, so a foreground probe connects immediately, is treated as a wake, + /// disconnects, re-arms, and churns. When we're holding the connection (foreground, or a background Pod + /// Keep Alive mode) the pod is already connected and the heartbeat rides that link; the probe would + /// fight it. Isolated to managerQueue (all probe call sites run there). private var delayedConnectProbeActive: Bool { - heartbeatEnabled && !isAppForeground + heartbeatEnabled && !shouldHoldConnection } /// Enable/disable and schedule the pump-provided heartbeat (delayed-connect loop). Driven by @@ -345,9 +359,9 @@ class BluetoothManager: NSObject { } } else if wasEnabled { // Stop the loop; fall back to connect-on-demand + alarm scan. Don't drop a live connection - // while foregrounded (keep-alive owns it then). + // while we're holding it (foreground keep-alive, or a background Pod Keep Alive mode). self.delayedProbeInFlight = false - if !self.isAppForeground { + if !self.shouldHoldConnection { for device in self.devices where device.manager.peripheral.state != .disconnected { self.manager.cancelPeripheralConnection(device.manager.peripheral) } @@ -718,11 +732,23 @@ class BluetoothManager: NSObject { } } - /// App entered the background: drop the kept-alive connection and resume the ~5-min heartbeat probe. + /// App entered the background: normally drop the kept-alive connection and resume the heartbeat probe. + /// EXCEPTION — a background Pod Keep Alive mode (silentTune / rileyLink, DASH): keep the pod connected, + /// because those modes exist for phone/pod combos where a disconnect→reconnect is unreliable. The + /// keep-alive's periodic status refresh maintains the link; we just leave it connected and don't probe. private func enterBackground() { dispatchPrecondition(condition: .onQueue(managerQueue)) isAppForeground = false guard let peripheral = keepAlivePeripheral else { return } + if shouldHoldConnection { // background Pod Keep Alive mode — do NOT disconnect + log.default("[connectOnDemand] background — Pod Keep Alive holding connection (no disconnect)") + connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] background — Pod Keep Alive holding connection") + if peripheral.state == .disconnected { + // We want it held connected but it's currently down — reconnect so keep-alive can refresh it. + beginCommandConnect(peripheral) + } + return + } commandConnectInFlight = false // deliberate disconnect: let the probe re-arm if peripheral.state == .connected || peripheral.state == .connecting { log.default("[connectOnDemand] background — disconnecting, resuming heartbeat probe") @@ -1167,11 +1193,12 @@ extension BluetoothManager: CBCentralManagerDelegate { autoReconnect(peripheral) } delayedProbeInFlight = false - if appIsForeground && commandConnectInFlight { - // Foreground keep-alive: an unintended drop while we want to stay connected (a deliberate - // background/idle disconnect clears commandConnectInFlight first, so it won't reconnect). - log.default("[connectOnDemand] foreground keep-alive — reconnecting after drop") - connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] foreground keep-alive — reconnecting after drop") + if shouldHoldConnection && commandConnectInFlight { + // Keep-alive (foreground, or a background Pod Keep Alive mode): an unintended drop while we want + // to stay connected (a deliberate background/idle disconnect clears commandConnectInFlight first, + // so it won't reconnect). + log.default("[connectOnDemand] keep-alive — reconnecting after drop") + connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] keep-alive — reconnecting after drop") connectViaFreshDiscovery(peripheral) } else { // Idle: run the fault-listener alarm scan, AND (if a heartbeat is needed) arm the StartDelay diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index 7566f30..9684056 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -710,11 +710,12 @@ extension PeripheralManager { let idleAt = idleStart queue.asyncAfter(deadline: .now() + idleDelay) { [weak self] in guard let self = self, BluetoothManager.connectOnDemandEnabled else { return } - // Foreground keep-alive: while the app is active, stay connected so connection-gated UI - // (test beeps, etc.) is live and in-app commands are instant. The background transition - // disconnects and resumes the heartbeat probe. - if self.bluetoothManager?.appIsForeground == true { - self.log.default("[connectOnDemand] app foreground — keeping pod connected (skip idle-disconnect)") + // Keep-alive: skip the idle-disconnect whenever we want the pod held connected — while the app + // is active (foreground keep-alive: connection-gated UI live, in-app commands instant), OR when + // a background Pod Keep Alive mode (silentTune/rileyLink) is selected for phone/pod combos where + // a disconnect→reconnect is unreliable. When Pod Keep Alive is disabled this is just foreground. + if self.bluetoothManager?.shouldHoldConnection == true { + self.log.default("[connectOnDemand] holding connection (keep-alive) — skip idle-disconnect") return } // Only disconnect if we're still idle (no newer session) and nothing is queued/running. diff --git a/OmnipodKit/PumpManagerUI/Views/PodKeepAliveView.swift b/OmnipodKit/PumpManagerUI/Views/PodKeepAliveView.swift index 80f6b2d..5aa6bef 100644 --- a/OmnipodKit/PumpManagerUI/Views/PodKeepAliveView.swift +++ b/OmnipodKit/PumpManagerUI/Views/PodKeepAliveView.swift @@ -273,6 +273,20 @@ enum PodKeepAlive: Int, CaseIterable, Codable { } } + /// Modes that keep the pod connected even while the app is backgrounded / phone locked (silentTune via + /// a background silent-tune, rileyLink via a BLE wake device). The connection layer holds the pod + /// connected in these modes instead of applying connect-on-demand's idle/background disconnect. + /// `.whenOpen` keeps alive only while foregrounded (already covered by the foreground keep-alive), and + /// `.disabled` is nominal connect-on-demand — both are false here. + var keepsPodConnectedInBackground: Bool { + switch self { + case .silentTune, .rileyLink: + return true + case .disabled, .whenOpen: + return false + } + } + var heartBeatInterval: TimeInterval? { switch self { case .rileyLink: From 6421781358c8cc978d60bacd4c813e5b0ad96eca Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 18 Jul 2026 18:56:48 -0500 Subject: [PATCH 88/92] Remove dead SLPE transport functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendSlpeCommandFireAndForget and sendSlpeGetSetCommand were only used by the removed periodic-status (SN2.0=) experiment; no remaining callers. (lastPodStatusWord is still live — it drives the DASH connectionless alert/fault decode in detectPodAlertStatus — so it stays.) --- .../Bluetooth/BleMessageTransport.swift | 103 ------------------ 1 file changed, 103 deletions(-) diff --git a/OmnipodKit/Bluetooth/BleMessageTransport.swift b/OmnipodKit/Bluetooth/BleMessageTransport.swift index 377deaa..a6c9471 100644 --- a/OmnipodKit/Bluetooth/BleMessageTransport.swift +++ b/OmnipodKit/Bluetooth/BleMessageTransport.swift @@ -596,109 +596,6 @@ class BlePodMessageTransport: MessageTransport { return responseData } - /// Send a STANDARD SLPE (2-byte length-prefixed) S…=/,G… command and parse the - /// length-prefixed response with parseKeys. This matches how standard S0.0=…,G0.0 commands - /// are framed (unlike sendO5AidCommand, which is plain-ASCII AID with a bare prefix-strip). - /// Uses a NON-disconnecting read so an unrecognized/unparseable command (silent pod) logs a - /// failure instead of dropping a live pod. - func sendSlpeGetSetCommand(keys: [String], payloads: [Data], responseKeys: [String]) throws -> [Data] { - guard let enDecrypt = self.enDecrypt else { - throw PodCommsError.podNotConnected - } - guard manager.peripheral.state == .connected else { - throw PodCommsError.podNotConnected - } - - let wrappedPayload = StringLengthPrefixEncoding.formatKeys(keys: keys, payloads: payloads) - - incrementMsgSeq() - let msg = MessagePacket( - type: MessageType.ENCRYPTED, - source: self.myId, - destination: self.podId, - payload: wrappedPayload, - sequenceNumber: UInt8(msgSeq), - eqos: 1 - ) - incrementNonceSeq() - let encrypted = try enDecrypt.encrypt(msg, nonceSeq) - - log.default("SLPE Send (%{public}d bytes): %{public}@", wrappedPayload.count, wrappedPayload.hexadecimalString) - messageLogger?.didSend(wrappedPayload) - - let writeResult = manager.sendMessagePacket(encrypted) - switch writeResult { - case .sentWithAcknowledgment: - break - case .sentWithError(let error): - throw PodCommsError.commsError(error: error) - case .unsentWithError(let error): - throw PodCommsError.commsError(error: error) - } - - // NON-disconnecting read: a silent pod (unparseable/unknown command) must NOT drop the link. - guard let readMessage = try manager.readMessagePacket(disconnectOnUnresponsivePod: false) else { - throw PodProtocolError.messageIOException("No SLPE response (pod silent)") - } - - incrementNonceSeq() - let decrypted = try enDecrypt.decrypt(readMessage, nonceSeq) - - log.default("SLPE RawResp: type=%{public}@ seq=%{public}d len=%{public}d hex=%{public}@ ascii=%{public}@ respKeys=%{public}@", - String(describing: decrypted.type), decrypted.sequenceNumber, decrypted.payload.count, - decrypted.payload.hexadecimalString, String(data: decrypted.payload, encoding: .utf8) ?? "", responseKeys.joined()) - - // ACK the response before parsing, so a parse failure still leaves the pod acknowledged. - incrementMsgSeq() - incrementNonceSeq() - let ack = try getAck(response: decrypted) - if case .sentWithAcknowledgment = manager.sendMessagePacket(ack) {} else { - log.error("SLPE: could not send ACK for response") - } - - return try StringLengthPrefixEncoding.parseKeys(responseKeys, decrypted.payload) - } - - /// Send a STANDARD SLPE command with NO synchronous read — for fire-and-forget commands like - /// periodic-status registration, where the pod sends no immediate reply and success is the - /// write being ACK'd (the real confirmation arrives later as an unsolicited push). Advances - /// msgSeq+1/nonceSeq+1 for the send only; the pod advances the same on receipt, so comms stay - /// in sync. Throws only if the write itself is not acknowledged. - func sendSlpeCommandFireAndForget(keys: [String], payloads: [Data]) throws { - guard let enDecrypt = self.enDecrypt else { - throw PodCommsError.podNotConnected - } - guard manager.peripheral.state == .connected else { - throw PodCommsError.podNotConnected - } - - let wrappedPayload = StringLengthPrefixEncoding.formatKeys(keys: keys, payloads: payloads) - - incrementMsgSeq() - let msg = MessagePacket( - type: MessageType.ENCRYPTED, - source: self.myId, - destination: self.podId, - payload: wrappedPayload, - sequenceNumber: UInt8(msgSeq), - eqos: 1 - ) - incrementNonceSeq() - let encrypted = try enDecrypt.encrypt(msg, nonceSeq) - - log.default("SLPE Send (fire-and-forget, %{public}d bytes): %{public}@", wrappedPayload.count, wrappedPayload.hexadecimalString) - messageLogger?.didSend(wrappedPayload) - - switch manager.sendMessagePacket(encrypted) { - case .sentWithAcknowledgment: - return - case .sentWithError(let error): - throw PodCommsError.commsError(error: error) - case .unsentWithError(let error): - throw PodCommsError.commsError(error: error) - } - } - func assertOnSessionQueue() { dispatchPrecondition(condition: .onQueue(manager.queue)) } From bc3484fb448a2d410f0ac1a84a31e18e1a0ffde6 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 18 Jul 2026 20:27:32 -0500 Subject: [PATCH 89/92] Drive the BLE heartbeat from a timer while the pod is held connected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The StartDelay probe only fires while the pod is DISCONNECTED (it needs a disconnected peripheral, and iOS only honors StartDelay for a suspended app). When the pod is instead held CONNECTED, the probe can't run — and for a network CGM the pump heartbeat is the only loop driver, so a held connection STALLS the loop. Observed in a tester report: an O5 pod stayed connected ~12 min (its heartbeat characteristic keeps resetting the idle-disconnect timer via handleHeartbeat, which never reschedules the check), the probe was suppressed the whole time, and no loop ran — a missed loop. Add a connected-state heartbeat timer that fires omnipodHeartbeatDidFire at the reading interval (aligned to heartbeatTargetDate) while the pod is connected, and reschedules itself. Started on didConnect, stopped on didDisconnect, and started/ stopped with the heartbeat request. It guards on peripheral.state == .connected, so normal connect-on-demand cycles (connect -> command -> ~15s idle-disconnect) stop it before it fires; it only drives when the link is held (O5 heartbeat char, foreground keep-alive, or a Pod Keep Alive mode). The StartDelay probe still owns the disconnected/suspended case; the two guard on opposite link states so never double-fire. Not device-tested yet. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 54 +++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index ea5378d..275f310 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -319,6 +319,9 @@ class BluetoothManager: NSObject { /// demand. managerQueue-isolated. private var heartbeatEnabled = false + /// Generation token for the connected-state heartbeat timer. Bumped to invalidate a pending fire. + private var heartbeatTimerGeneration = 0 + /// The delayed-connect (StartDelay) heartbeat probe runs when Loop asks the pump to provide the BLE /// heartbeat (`heartbeatEnabled`, via PumpManager.setBLEHeartbeatRequest) AND we are NOT holding the pod /// connected. CBConnectPeripheralOptionStartDelayKey is a background-only mechanism — iOS ignores the @@ -352,15 +355,18 @@ class BluetoothManager: NSObject { self.log.default("[heartbeat] pid=%{public}d providesHeartbeat=%{public}@ targetIn=%{public}@", pid, String(enabled), targetDesc) self.connectionDelegate?.omnipodLogDeviceEvent("[heartbeat] pid=\(pid) providesHeartbeat=\(enabled) targetIn=\(targetDesc)") if enabled { - // (Re)arm against the known autoconnect pod (nil if no active pod — don't probe a - // stale/discarded device). No-ops if a probe is already in flight. + // Deliver the heartbeat by whichever driver fits the current link state — these guard on + // opposite states, so only one acts: the StartDelay probe when DISCONNECTED, the timer when + // CONNECTED (held link). (Re)arm against the known autoconnect pod (nil if no active pod). if let peripheral = self.keepAlivePeripheral { - self.issueDelayedConnectProbe(peripheral) + self.issueDelayedConnectProbe(peripheral) // no-op unless disconnected + not held } + self.startConnectedHeartbeatTimer() // no-op unless connected } else if wasEnabled { // Stop the loop; fall back to connect-on-demand + alarm scan. Don't drop a live connection // while we're holding it (foreground keep-alive, or a background Pod Keep Alive mode). self.delayedProbeInFlight = false + self.stopConnectedHeartbeatTimer() if !self.shouldHoldConnection { for device in self.devices where device.manager.peripheral.state != .disconnected { self.manager.cancelPeripheralConnection(device.manager.peripheral) @@ -413,6 +419,39 @@ class BluetoothManager: NSObject { manager.connect(peripheral, options: [CBConnectPeripheralOptionStartDelayKey: NSNumber(value: delaySeconds)]) } + /// Connected-state heartbeat driver. The StartDelay probe only fires while the pod is DISCONNECTED + /// (it needs a disconnected peripheral, and iOS only honors StartDelay for a suspended app). When the + /// pod is instead held CONNECTED — foreground keep-alive, a Pod Keep Alive mode, or the O5 heartbeat + /// characteristic keeping the link alive — the probe can't run, and for a pump-provided heartbeat + /// (network CGM, the only loop driver) a held connection would otherwise STALL the loop (observed: a + /// ~12-min missed loop on O5). While connected, the app is kept alive by the BLE session, so we fire + /// the heartbeat from a timer instead. Aligned to `heartbeatTargetDate`; reschedules itself. Started on + /// didConnect, stopped on didDisconnect — so normal connect-on-demand cycles (connect → command → + /// idle-disconnect in ~15s) stop it long before it fires; it only ever drives when the link is held. + private func startConnectedHeartbeatTimer() { + dispatchPrecondition(condition: .onQueue(managerQueue)) + heartbeatTimerGeneration += 1 + guard heartbeatEnabled, keepAlivePeripheral?.state == .connected else { return } + let gen = heartbeatTimerGeneration + let target = heartbeatTargetDate ?? Date().addingTimeInterval(TimeInterval(BluetoothManager.delayedConnectProbeSeconds)) + let delay = max(BluetoothManager.heartbeatMinDelaySeconds, target.timeIntervalSinceNow) + managerQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self = self, gen == self.heartbeatTimerGeneration, self.heartbeatEnabled, + self.keepAlivePeripheral?.state == .connected else { return } + let pid = ProcessInfo.processInfo.processIdentifier + self.log.default("[heartbeat] pid=%{public}d connected-state timer fired — driving heartbeat (held connection)", pid) + self.connectionDelegate?.omnipodLogDeviceEvent("[heartbeat] pid=\(pid) connected-state timer — driving heartbeat (held connection)") + self.connectionDelegate?.omnipodHeartbeatDidFire() + self.startConnectedHeartbeatTimer() // reschedule for the next interval + } + } + + /// Stop the connected-state heartbeat timer (invalidate any pending fire). + private func stopConnectedHeartbeatTimer() { + dispatchPrecondition(condition: .onQueue(managerQueue)) + heartbeatTimerGeneration += 1 + } + /// The keep-connected auto-reconnect. Suppressed in connect-on-demand mode, where the pod is /// left disconnected between commands (and observable via advertisements) and connected on /// demand by PeripheralManager. Explicit connects (pairing, retrieveAndConnectKnownPod, the @@ -1173,6 +1212,12 @@ extension BluetoothManager: CBCentralManagerDelegate { // Get an RSSI reading for logging peripheral.readRSSI() } + + // While the link is held connected the StartDelay probe can't fire; drive the heartbeat from a + // timer instead so a network CGM keeps looping. No-op unless this is our pod + heartbeat is on. + if autoConnectIDs.contains(peripheral.identifier.uuidString) { + startConnectedHeartbeatTimer() + } } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { @@ -1181,6 +1226,9 @@ extension BluetoothManager: CBCentralManagerDelegate { log.default("[#%{public}@] DISCONNECTED: %{public}@ error=%{public}@ willReconnect=%{public}@", instanceID, peripheral, String(describing: error), String(describing: autoConnectIDs.contains(peripheral.identifier.uuidString))) + // Link is down — the StartDelay probe (re-armed below) owns the heartbeat now; stop the timer. + stopConnectedHeartbeatTimer() + // Proxy disconnection events to peripheral manager for device in devices where device.manager.peripheral.identifier == peripheral.identifier { device.manager.centralManager(central, didDisconnect: peripheral, error: error) From 04ccda8484c807f81f5681ae8096b3652ba45d30 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 18 Jul 2026 23:54:42 -0500 Subject: [PATCH 90/92] Revert "Drive the BLE heartbeat from a timer while the pod is held connected" This reverts commit bc3484fb448a2d410f0ac1a84a31e18e1a0ffde6. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 54 ++------------------- 1 file changed, 3 insertions(+), 51 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 275f310..ea5378d 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -319,9 +319,6 @@ class BluetoothManager: NSObject { /// demand. managerQueue-isolated. private var heartbeatEnabled = false - /// Generation token for the connected-state heartbeat timer. Bumped to invalidate a pending fire. - private var heartbeatTimerGeneration = 0 - /// The delayed-connect (StartDelay) heartbeat probe runs when Loop asks the pump to provide the BLE /// heartbeat (`heartbeatEnabled`, via PumpManager.setBLEHeartbeatRequest) AND we are NOT holding the pod /// connected. CBConnectPeripheralOptionStartDelayKey is a background-only mechanism — iOS ignores the @@ -355,18 +352,15 @@ class BluetoothManager: NSObject { self.log.default("[heartbeat] pid=%{public}d providesHeartbeat=%{public}@ targetIn=%{public}@", pid, String(enabled), targetDesc) self.connectionDelegate?.omnipodLogDeviceEvent("[heartbeat] pid=\(pid) providesHeartbeat=\(enabled) targetIn=\(targetDesc)") if enabled { - // Deliver the heartbeat by whichever driver fits the current link state — these guard on - // opposite states, so only one acts: the StartDelay probe when DISCONNECTED, the timer when - // CONNECTED (held link). (Re)arm against the known autoconnect pod (nil if no active pod). + // (Re)arm against the known autoconnect pod (nil if no active pod — don't probe a + // stale/discarded device). No-ops if a probe is already in flight. if let peripheral = self.keepAlivePeripheral { - self.issueDelayedConnectProbe(peripheral) // no-op unless disconnected + not held + self.issueDelayedConnectProbe(peripheral) } - self.startConnectedHeartbeatTimer() // no-op unless connected } else if wasEnabled { // Stop the loop; fall back to connect-on-demand + alarm scan. Don't drop a live connection // while we're holding it (foreground keep-alive, or a background Pod Keep Alive mode). self.delayedProbeInFlight = false - self.stopConnectedHeartbeatTimer() if !self.shouldHoldConnection { for device in self.devices where device.manager.peripheral.state != .disconnected { self.manager.cancelPeripheralConnection(device.manager.peripheral) @@ -419,39 +413,6 @@ class BluetoothManager: NSObject { manager.connect(peripheral, options: [CBConnectPeripheralOptionStartDelayKey: NSNumber(value: delaySeconds)]) } - /// Connected-state heartbeat driver. The StartDelay probe only fires while the pod is DISCONNECTED - /// (it needs a disconnected peripheral, and iOS only honors StartDelay for a suspended app). When the - /// pod is instead held CONNECTED — foreground keep-alive, a Pod Keep Alive mode, or the O5 heartbeat - /// characteristic keeping the link alive — the probe can't run, and for a pump-provided heartbeat - /// (network CGM, the only loop driver) a held connection would otherwise STALL the loop (observed: a - /// ~12-min missed loop on O5). While connected, the app is kept alive by the BLE session, so we fire - /// the heartbeat from a timer instead. Aligned to `heartbeatTargetDate`; reschedules itself. Started on - /// didConnect, stopped on didDisconnect — so normal connect-on-demand cycles (connect → command → - /// idle-disconnect in ~15s) stop it long before it fires; it only ever drives when the link is held. - private func startConnectedHeartbeatTimer() { - dispatchPrecondition(condition: .onQueue(managerQueue)) - heartbeatTimerGeneration += 1 - guard heartbeatEnabled, keepAlivePeripheral?.state == .connected else { return } - let gen = heartbeatTimerGeneration - let target = heartbeatTargetDate ?? Date().addingTimeInterval(TimeInterval(BluetoothManager.delayedConnectProbeSeconds)) - let delay = max(BluetoothManager.heartbeatMinDelaySeconds, target.timeIntervalSinceNow) - managerQueue.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self = self, gen == self.heartbeatTimerGeneration, self.heartbeatEnabled, - self.keepAlivePeripheral?.state == .connected else { return } - let pid = ProcessInfo.processInfo.processIdentifier - self.log.default("[heartbeat] pid=%{public}d connected-state timer fired — driving heartbeat (held connection)", pid) - self.connectionDelegate?.omnipodLogDeviceEvent("[heartbeat] pid=\(pid) connected-state timer — driving heartbeat (held connection)") - self.connectionDelegate?.omnipodHeartbeatDidFire() - self.startConnectedHeartbeatTimer() // reschedule for the next interval - } - } - - /// Stop the connected-state heartbeat timer (invalidate any pending fire). - private func stopConnectedHeartbeatTimer() { - dispatchPrecondition(condition: .onQueue(managerQueue)) - heartbeatTimerGeneration += 1 - } - /// The keep-connected auto-reconnect. Suppressed in connect-on-demand mode, where the pod is /// left disconnected between commands (and observable via advertisements) and connected on /// demand by PeripheralManager. Explicit connects (pairing, retrieveAndConnectKnownPod, the @@ -1212,12 +1173,6 @@ extension BluetoothManager: CBCentralManagerDelegate { // Get an RSSI reading for logging peripheral.readRSSI() } - - // While the link is held connected the StartDelay probe can't fire; drive the heartbeat from a - // timer instead so a network CGM keeps looping. No-op unless this is our pod + heartbeat is on. - if autoConnectIDs.contains(peripheral.identifier.uuidString) { - startConnectedHeartbeatTimer() - } } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { @@ -1226,9 +1181,6 @@ extension BluetoothManager: CBCentralManagerDelegate { log.default("[#%{public}@] DISCONNECTED: %{public}@ error=%{public}@ willReconnect=%{public}@", instanceID, peripheral, String(describing: error), String(describing: autoConnectIDs.contains(peripheral.identifier.uuidString))) - // Link is down — the StartDelay probe (re-armed below) owns the heartbeat now; stop the timer. - stopConnectedHeartbeatTimer() - // Proxy disconnection events to peripheral manager for device in devices where device.manager.peripheral.identifier == peripheral.identifier { device.manager.centralManager(central, didDisconnect: peripheral, error: error) From 25d555188f447d34175cd342090d11f9266cfc24 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 18 Jul 2026 23:57:14 -0500 Subject: [PATCH 91/92] Disconnect promptly in the background so the StartDelay probe re-arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the tester's ~12-min missed loop (O5 + network CGM): after a background heartbeat-wake cycle, the 15s idle-disconnect didn't fire before iOS suspended the app. The idle-disconnect asyncAfter froze mid-window, the pod stayed connected, and the StartDelay probe (which needs a DISCONNECTED pod) could never re-arm — so nothing woke the app until it resumed ~11 min later (which fired the frozen disconnect at exactly the resume instant). (Corrects the earlier diagnosis: the O5 'heartbeat characteristic' 7DED7A6D is an unconfirmed placeholder the real pod doesn't expose — enableNotifications skips the undiscovered service — so handleHeartbeat never fires and was NOT the cause. The reverted connected-state timer also wouldn't help: asyncAfter freezes while suspended.) Fix: shorten the idle-disconnect delay 15s -> 4s (BluetoothManager.idleDisconnectSeconds, tunable) so the disconnect lands well within the background execution window and the probe re-arms before suspension. The status->dose burst still shares one connection (each session resets idleStart, so the delay is measured from the LAST command); foreground / Pod Keep Alive hold the connection separately, so this only bites while backgrounded. Not device-tested. --- OmnipodKit/Bluetooth/BluetoothManager.swift | 11 +++++++++++ OmnipodKit/Bluetooth/PeripheralManager.swift | 10 ++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/OmnipodKit/Bluetooth/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index ea5378d..ac1b61e 100644 --- a/OmnipodKit/Bluetooth/BluetoothManager.swift +++ b/OmnipodKit/Bluetooth/BluetoothManager.swift @@ -242,6 +242,17 @@ class BluetoothManager: NSObject { (UserDefaults.standard.object(forKey: "OmnipodKit.heartbeatFailureBackoffSeconds") as? Double) ?? 30 } + /// Idle-disconnect delay (seconds) after the last command's session. Kept SHORT so that in the + /// background a heartbeat-wake cycle disconnects promptly — before iOS suspends the app — which lets + /// the StartDelay probe re-arm (it needs a DISCONNECTED pod). A long delay let the app suspend with the + /// link still up and the timer frozen, so the probe never re-armed → a ~12-min missed loop (tester + /// report). Foreground / Pod Keep Alive hold the connection separately (`shouldHoldConnection`), so this + /// only takes effect while backgrounded. The status→dose burst still shares one connection: each session + /// resets `idleStart`, so the disconnect lands this many seconds after the LAST command. + static var idleDisconnectSeconds: TimeInterval { + (UserDefaults.standard.object(forKey: "OmnipodKit.idleDisconnectSeconds") as? Double) ?? 4 + } + /// Candidate DASH alarm-state service UUIDs to filter on in low-power mode. /// - `C005`: CONFIRMED 16-bit alarm 2nd-UUID on this pod (expiration reminder). Extend as more /// alert/alarm types are captured. diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index 9684056..735a630 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -701,12 +701,14 @@ extension PeripheralManager { /// Connect-on-demand: after a session goes idle, if no further session is queued, disconnect /// the pod so it's left "normally disconnected" (and advertising/observable) between commands. - /// The delay batches a Loop cycle's command sequence (status read → dose decision → dose enact) into - /// one connection: Loop runs its algorithm between the status read and the dose command, so the window - /// must be long enough to span that gap and avoid a reconnect mid-cycle. + /// The delay is kept SHORT (see `BluetoothManager.idleDisconnectSeconds`) so a background heartbeat-wake + /// cycle disconnects before iOS suspends the app — letting the StartDelay probe re-arm (it needs a + /// disconnected pod). The status→dose burst still shares one connection: each session resets `idleStart`, + /// so the disconnect only lands this many seconds after the LAST command. (Foreground / Pod Keep Alive + /// hold the connection separately via `shouldHoldConnection`, so this delay only bites while backgrounded.) private func scheduleIdleDisconnectIfNeeded() { guard BluetoothManager.connectOnDemandEnabled else { return } - let idleDelay: TimeInterval = 15 + let idleDelay: TimeInterval = BluetoothManager.idleDisconnectSeconds let idleAt = idleStart queue.asyncAfter(deadline: .now() + idleDelay) { [weak self] in guard let self = self, BluetoothManager.connectOnDemandEnabled else { return } From fd38fca4af438303c6a51c6dea62b23d600a9d71 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 19 Jul 2026 10:33:59 -0500 Subject: [PATCH 92/92] Remove dead O5 heartbeat-characteristic code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The O5 'heartbeat' characteristic/service (7DED7A6C/7DED7A6D) was an unconfirmed placeholder the real pod doesn't expose — enableNotifications silently skips the undiscovered service/char, so it's never subscribed and handleHeartbeat never fires. Remove the whole dead path: - PeripheralManager.handleHeartbeat, .lastHeartbeatTime, mostRecentHeartbeatTime - o5Omnipod5HeartbeatServiceUUID / o5Omnipod5HeartbeatCharacteristicUUID enums - BlePodProfile heartbeatServiceUUID/heartbeatCharacteristicUUID fields + their serviceCharacteristics/notifyingCharacteristics/valueUpdateMacros wiring Unrelated: the RileyLink keep-alive device path in PodKeepAliveView (RileyLinkHeartbeatBluetoothDevice, its own lastHeartbeatTime, expectedHeartbeatInterval) is a separate live mechanism and is untouched. No behavior change (the removed char was never functional). --- OmnipodKit/Bluetooth/BlePodProfile.swift | 27 +++----------------- OmnipodKit/Bluetooth/BluetoothServices.swift | 11 -------- OmnipodKit/Bluetooth/PeripheralManager.swift | 22 ---------------- 3 files changed, 3 insertions(+), 57 deletions(-) diff --git a/OmnipodKit/Bluetooth/BlePodProfile.swift b/OmnipodKit/Bluetooth/BlePodProfile.swift index 62af27b..a448048 100644 --- a/OmnipodKit/Bluetooth/BlePodProfile.swift +++ b/OmnipodKit/Bluetooth/BlePodProfile.swift @@ -42,30 +42,19 @@ struct BlePodProfile { let serviceUUID: CBUUID let commandCharacteristicUUID: CBUUID let dataCharacteristicUUID: CBUUID - let heartbeatServiceUUID: CBUUID? - let heartbeatCharacteristicUUID: CBUUID? let commandWriteType: CBCharacteristicWriteType let packetLayout: BlePacketLayout func makePeripheralConfiguration() -> PeripheralManager.Configuration { - var serviceCharacteristics: [CBUUID: [CBUUID]] = [ + let serviceCharacteristics: [CBUUID: [CBUUID]] = [ serviceUUID: [commandCharacteristicUUID, dataCharacteristicUUID] ] - if let heartbeatServiceUUID = heartbeatServiceUUID, - let heartbeatCharacteristicUUID = heartbeatCharacteristicUUID { - serviceCharacteristics[heartbeatServiceUUID] = [heartbeatCharacteristicUUID] - } - - var notifyingCharacteristics: [CBUUID: [CBUUID]] = [ + let notifyingCharacteristics: [CBUUID: [CBUUID]] = [ serviceUUID: [] ] - if let heartbeatServiceUUID = heartbeatServiceUUID, - let heartbeatCharacteristicUUID = heartbeatCharacteristicUUID { - notifyingCharacteristics[heartbeatServiceUUID] = [heartbeatCharacteristicUUID] - } - var valueUpdateMacros: [CBUUID: (_ manager: PeripheralManager) -> Void] = [ + let valueUpdateMacros: [CBUUID: (_ manager: PeripheralManager) -> Void] = [ commandCharacteristicUUID: { (manager: PeripheralManager) in guard let characteristic = manager.peripheral.getCommandCharacteristic(profile: manager.profile) else { return } guard let value = characteristic.value else { return } @@ -90,12 +79,6 @@ struct BlePodProfile { } ] - if let heartbeatCharacteristicUUID = heartbeatCharacteristicUUID { - valueUpdateMacros[heartbeatCharacteristicUUID] = { (manager: PeripheralManager) in - manager.handleHeartbeat() - } - } - return PeripheralManager.Configuration( serviceCharacteristics: serviceCharacteristics, notifyingCharacteristics: notifyingCharacteristics, @@ -111,8 +94,6 @@ extension BlePodProfile { serviceUUID: dashOmnipodServiceUUID.service.cbUUID, commandCharacteristicUUID: dashOmnipodCharacteristicUUID.command.cbUUID, dataCharacteristicUUID: dashOmnipodCharacteristicUUID.data.cbUUID, - heartbeatServiceUUID: nil, - heartbeatCharacteristicUUID: nil, commandWriteType: .withResponse, packetLayout: BlePacketLayout( maxPayloadSize: 20, @@ -129,8 +110,6 @@ extension BlePodProfile { serviceUUID: o5OmnipodServiceUUID.service.cbUUID, commandCharacteristicUUID: o5OmnipodCharacteristicUUID.command.cbUUID, dataCharacteristicUUID: o5OmnipodCharacteristicUUID.data.cbUUID, - heartbeatServiceUUID: o5Omnipod5HeartbeatServiceUUID.service.cbUUID, - heartbeatCharacteristicUUID: o5Omnipod5HeartbeatCharacteristicUUID.heartbeat.cbUUID, commandWriteType: .withoutResponse, packetLayout: BlePacketLayout( maxPayloadSize: 244, diff --git a/OmnipodKit/Bluetooth/BluetoothServices.swift b/OmnipodKit/Bluetooth/BluetoothServices.swift index 6e148dc..f7d7f39 100644 --- a/OmnipodKit/Bluetooth/BluetoothServices.swift +++ b/OmnipodKit/Bluetooth/BluetoothServices.swift @@ -54,14 +54,3 @@ enum o5OmnipodCharacteristicUUID: String, CBUUIDRawValue { case command = "1A7E2441-E3ED-4464-8B7E-751E03D0DC5F" // Same as DASH case data = "1A7E2443-E3ED-4464-8B7E-751E03D0DC5F" // Similar to DASH, but with 2443 instead of 2442 } - -// Omnipod 5 Heartbeat Service - used for O5 pod keep-alive -enum o5Omnipod5HeartbeatServiceUUID: String, CBUUIDRawValue { - case advertisement = "ECF301E2-674B-4474-94D0-364F3AA653E6" - case service = "7DED7A6C-CA72-46A7-A3A2-6061F6FDCAEB" -} - -enum o5Omnipod5HeartbeatCharacteristicUUID: String, CBUUIDRawValue { - // The heartbeat characteristic UUID - to be confirmed via BLE service discovery - case heartbeat = "7DED7A6D-CA72-46A7-A3A2-6061F6FDCAEB" -} diff --git a/OmnipodKit/Bluetooth/PeripheralManager.swift b/OmnipodKit/Bluetooth/PeripheralManager.swift index 735a630..4885524 100644 --- a/OmnipodKit/Bluetooth/PeripheralManager.swift +++ b/OmnipodKit/Bluetooth/PeripheralManager.swift @@ -660,28 +660,6 @@ extension CBPeripheral { } -// MARK: - O5 Heartbeat handling -extension PeripheralManager { - /// Timestamp of the last heartbeat received from the O5 pod - private static var lastHeartbeatTime: Date? - - static func mostRecentHeartbeatTime() -> Date? { - return lastHeartbeatTime - } - - /// Handle a heartbeat notification from the O5 pod. - /// This resets the idle timer to keep the connection alive. - func handleHeartbeat() { - PeripheralManager.lastHeartbeatTime = Date() - log.debug("Received O5 heartbeat at %{public}@", String(describing: PeripheralManager.lastHeartbeatTime)) - - // Reset idle timer when we receive a heartbeat - self.queue.async { - self.idleStart = Date() - } - } -} - // MARK: - Command session management extension PeripheralManager { func runSession(withName name: String , _ block: @escaping () -> Void) {