diff --git a/DASH_BEACON_FINDINGS.md b/DASH_BEACON_FINDINGS.md new file mode 100644 index 0000000..e83028d --- /dev/null +++ b/DASH_BEACON_FINDINGS.md @@ -0,0 +1,115 @@ +# 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. + +--- + +# 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/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). 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 2df476e..8bca271 100644 --- a/OmnipodKit/Bluetooth/BlePodComms.swift +++ b/OmnipodKit/Bluetooth/BlePodComms.swift @@ -55,6 +55,12 @@ class BlePodComms: PodComms { super.forgetPod() } + /// 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 // "Bluetooth use unsupported on this device" errors on the // next BlePodComms instantiation and subsequent usage. @@ -631,7 +637,27 @@ class BlePodComms: PodComms { func bleRunSession(withName name: String, _ block: @escaping (_ result: SessionRunResult) -> Void) { - guard let manager = manager, manager.peripheral.state == .connected else { + // 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 + } + // 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 } @@ -670,6 +696,24 @@ class BlePodComms: PodComms { // MARK: - OmniConnectionDelegate extension BlePodComms: OmniConnectionDelegate { + func omnipodLogDeviceEvent(_ message: String) { + delegate?.omnipodLogDeviceEvent(message) + } + + func omnipodHeartbeatDidFire() { + delegate?.omnipodHeartbeatDidFire() + } + + func omnipodDidDetectAlert(slots: AlertSet) { + 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) @@ -682,6 +726,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 +735,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 +785,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)) @@ -761,3 +808,65 @@ extension BlePodComms: PodCommsSessionDelegate { podState = state } } + +// MARK: - Unsolicited (pod-initiated) fault decrypt + logging (opt-in diagnostic) +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 needed 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}@ 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)") + } + // 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() + // (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)) + } + } + 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..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 } @@ -75,6 +64,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,15 +75,10 @@ struct BlePodProfile { manager.dataQueue.append(value) manager.queueLock.signal() manager.queueLock.unlock() + manager.noteInboundValueForUnsolicitedListener(characteristicUUID: characteristic.uuid, value: value) } ] - if let heartbeatCharacteristicUUID = heartbeatCharacteristicUUID { - valueUpdateMacros[heartbeatCharacteristicUUID] = { (manager: PeripheralManager) in - manager.handleHeartbeat() - } - } - return PeripheralManager.Configuration( serviceCharacteristics: serviceCharacteristics, notifyingCharacteristics: notifyingCharacteristics, @@ -109,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, @@ -127,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/BluetoothManager.swift b/OmnipodKit/Bluetooth/BluetoothManager.swift index 894cfea..ac1b61e 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) @@ -87,6 +88,24 @@ 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) + + /// 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) {} } @@ -103,7 +122,25 @@ 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] = [:] + + /// 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] = [:] + + /// 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 @@ -140,15 +177,304 @@ 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)) + + /// 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 + } + + /// 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: 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 + } + + /// 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 + } + + + /// 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 + } + + /// 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 + } + + /// 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. + /// - `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:) + /// 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"), + ] + + /// 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? + /// 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 + /// 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 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 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 + /// 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 (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 + /// 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 && !shouldHoldConnection + } + + /// 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 { + 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 + 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 { + // (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 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 + if !self.shouldHoldConnection { + 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 { + connectRequestedAt[peripheral.identifier.uuidString] = Date() + } + let cm: CBCentralManager = manager + cm.connect(peripheral, options: nil) + } + + /// 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). + guard !discoveryModeEnabled else { return } + guard delayedConnectProbeActive, !delayedProbeInFlight, !commandConnectInFlight, + 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 — 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)) + // 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 + // 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 = TimeInterval(delaySeconds) + let pid = ProcessInfo.processInfo.processIdentifier + 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 + /// 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() + log.default("BluetoothManager #%{public}@ INIT (podType=%{public}@)", instanceID, String(describing: podType)) + 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 + let pid = ProcessInfo.processInfo.processIdentifier + 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?.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() + } + } } - + + deinit { + log.default("BluetoothManager #%{public}@ DEINIT", instanceID) + } + @discardableResult private func addPeripheral(_ peripheral: CBPeripheral, podAdvertisement: PodAdvertisement?) -> Omni { dispatchPrecondition(condition: .onQueue(managerQueue)) @@ -158,11 +484,14 @@ 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 } } 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") } @@ -209,7 +538,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.autoReconnect(peripheral) } } } @@ -227,7 +556,7 @@ class BluetoothManager: NSObject { } let device = addPeripheral(peripheral, podAdvertisement: nil) autoConnectIDs.insert(uuidString) - manager.connect(peripheral, options: nil) + autoReconnect(peripheral) log.default("retrieveAndConnectKnownPod: initiating connection to %{public}@", peripheral) result = device } @@ -237,6 +566,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 } } @@ -251,7 +594,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) + autoReconnect(peripheral) } } else { if peripheral.state == .connected || peripheral.state == .connecting { @@ -274,11 +617,16 @@ 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 { log.info("discoverPods: Connecting to peripheral: %{public}@", peripheral) - manager.connect(peripheral, options: nil) + timedConnect(peripheral) // pairing/discovery — an explicit connect, not auto-reconnect } } startScanning() @@ -286,16 +634,190 @@ 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) + 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.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) + } + } + } + + /// 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) + } + + // 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 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 + 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. 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? { + 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 + /// 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: 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") + connectionDelegate?.omnipodLogDeviceEvent("[connectOnDemand] background — disconnecting, resuming heartbeat probe") + manager.cancelPeripheralConnection(peripheral) // didDisconnect resumes scan + arms probe } else { - serviceUUID = podType.blePodProfile.advertisementServiceUUID + resumeScanIfNeeded() // fault-listener scan while idle + issueDelayedConnectProbe(peripheral) // + heartbeat probe alongside (if needed) } - log.default("Start scanning for %{public}@", serviceUUID.uuidString) - manager.scanForPeripherals(withServices: [serviceUUID], options: nil) + } + + /// 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) + } + } + + 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) + connectionDelegate?.omnipodLogDeviceEvent("[pairing] scan started (filter=\(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 + } + 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 (O5-aware via podScanServiceUUID); + // allowDuplicates to see the advert cadence. + services = [serviceUUID] + options = BluetoothManager.advertisementMonitorEnabled ? [CBCentralManagerScanOptionAllowDuplicatesKey: true] : [:] + } + 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: options[CBCentralManagerScanOptionAllowDuplicatesKey] != nil)) + manager.scanForPeripherals(withServices: services, options: options) } private func stopScanning() { @@ -303,6 +825,30 @@ 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.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] { @@ -313,6 +859,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 = [ @@ -334,7 +891,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 @@ -342,7 +899,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) + autoReconnect(newPeripheral) } } @@ -353,15 +910,19 @@ 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) + autoReconnect(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() @@ -394,25 +955,171 @@ 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)) + } + + /// 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 + 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. + // 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) + 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: ",") + 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) + alarmScanSuppressed = true + if manager.isScanning { manager.stopScan() } + } + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { dispatchPrecondition(condition: .onQueue(managerQueue)) log.debug("%{public}@: %{public}@, %{public}@", #function, peripheral, advertisementData) - if let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data { + // 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 + // 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 ?? "-" + 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 ?? "-" + // 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("[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 + // 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 + 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 { log.default("[SCAN] ManufacturerData: %{public}@ (%{public}d bytes)", mfgData.hexadecimalString, mfgData.count) } + // 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. 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). + 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 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?.manager.connect(peripheral, options: nil) + } + } + // 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) { addPeripheral(peripheral, podAdvertisement: podAdvertisement) - - 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) + + if discoveryModeEnabled { + connectionDelegate?.omnipodLogDeviceEvent("[pairing] heard pod \(peripheral.identifier.uuidString) pairable=\(podAdvertisement.pairable) state=\(peripheral.state.rawValue)") + } + 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") - manager.connect(peripheral, options: nil) + autoReconnect(peripheral) } else { log.info("Ignoring paired or unconnectable peripheral: %{public}@", peripheral) } @@ -420,7 +1127,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() } @@ -429,8 +1136,46 @@ extension BluetoothManager: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { dispatchPrecondition(condition: .onQueue(managerQueue)) - log.debug("%{public}@: %{public}@", #function, peripheral) - + // 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() + } + + // 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. + // 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) + 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}@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. + pendingHeartbeatFire = true + 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}@)", + 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 { device.manager.centralManager(central, didConnect: peripheral) @@ -444,6 +1189,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) @@ -453,19 +1201,59 @@ extension BluetoothManager: CBCentralManagerDelegate { if autoConnectIDs.contains(peripheral.identifier.uuidString) { log.debug("Reconnecting disconnected autoconnect peripheral") - central.connect(peripheral, options: nil) + autoReconnect(peripheral) + } + delayedProbeInFlight = false + 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 + // 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 && autoConnectIDs.contains(peripheral.identifier.uuidString) { + 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. + if pendingHeartbeatFire { + pendingHeartbeatFire = false + log.default("[delayedConnect] firing heartbeat (pumpManagerBLEHeartbeatDidFire)") + connectionDelegate?.omnipodLogDeviceEvent("[delayedConnect] firing heartbeat (pumpManagerBLEHeartbeatDidFire)") + connectionDelegate?.omnipodHeartbeatDidFire() } } 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) if autoConnectIDs.contains(peripheral.identifier.uuidString) { - central.connect(peripheral, options: nil) + autoReconnect(peripheral) + } + delayedProbeInFlight = false + resumeScanIfNeeded() // keep the fault-listener alarm scan running while idle + if delayedConnectProbeActive && !commandConnectInFlight && autoConnectIDs.contains(peripheral.identifier.uuidString) { + // 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) + } } } } 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+OmnipodKit.swift b/OmnipodKit/Bluetooth/PeripheralManager+OmnipodKit.swift index 60211fa..ee8c1d5 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 { @@ -359,3 +359,70 @@ extension PeripheralManagerError { } } } + +// 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. 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 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 { + UserDefaults.standard.object(forKey: "OmnipodKit.unsolicitedFaultListenerEnabled") as? Bool ?? false + } + + /// 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 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: 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 + && 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 { + // 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 + } + 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..4885524 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 @@ -79,6 +82,12 @@ class PeripheralManager: NSObject { weak var delegate: PeripheralManagerDelegate? + /// 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 @@ -118,6 +127,25 @@ extension PeripheralManager { protocol PeripheralManagerDelegate: AnyObject { // Called from the PeripheralManager's queue func completeConfiguration(for manager: PeripheralManager) throws + + /// 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`. + 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 + } } @@ -126,7 +154,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) @@ -212,10 +251,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, @@ -314,6 +355,36 @@ 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)) + // 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 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() + 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. 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 + // 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))) + } + /// - Throws: PeripheralManagerError func setNotifyValue(_ enabled: Bool, for characteristic: CBCharacteristic, timeout: TimeInterval) throws { try runCommand(timeout: timeout) { @@ -461,7 +532,7 @@ extension PeripheralManager: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { commandLock.lock() - + if let macro = configuration.valueUpdateMacros[characteristic.uuid] { macro(self) } @@ -589,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) { @@ -623,7 +672,39 @@ 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. + /// 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 = BluetoothManager.idleDisconnectSeconds + let idleAt = idleStart + queue.asyncAfter(deadline: .now() + idleDelay) { [weak self] in + guard let self = self, BluetoothManager.connectOnDemandEnabled else { return } + // 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. + 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)) + // 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) + } + } } diff --git a/OmnipodKit/PumpManager/OmniPumpManager.swift b/OmnipodKit/PumpManager/OmniPumpManager.swift index 0c677dd..786b4a3 100644 --- a/OmnipodKit/PumpManager/OmniPumpManager.swift +++ b/OmnipodKit/PumpManager/OmniPumpManager.swift @@ -315,10 +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 + 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 + 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) } } @@ -356,6 +374,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) } @@ -363,6 +382,28 @@ public class OmniPumpManager: RileyLinkPumpManager { } } + func omnipodLogDeviceEvent(_ message: String) { + 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 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) @@ -1570,9 +1611,10 @@ extension OmniPumpManager { // Silence any pending acknowledged alerts silenceAcknowledgedAlerts() - // If we have a status return or if the pod is currently faulted, - // store the dosesForStorage which updates lastPumpDataReportDate - // and ensures that any updated doses will be saved to the client. + // If we have a status return or if the pod is currently faulted, store dosesForStorage — updates + // lastPumpDataReportDate and saves any updated doses to the client, including the in-progress + // bolus finalized by handlePodFault on a fault (upstream loopandlearn #99; supersedes our earlier + // didReadStatus flush for the incomplete-dose-on-fault case). if status != nil || state.podState?.isFaulted == true { session.dosesForStorage() { (doses) -> Bool in return store(doses: doses, in: session) @@ -2659,16 +2701,12 @@ extension OmniPumpManager: PumpManager { } public func runTemporaryBasalProgram(decisionId: UUID?, unitsPerHour: Double, for duration: TimeInterval, automatic: Bool, completion: @escaping (PumpManagerError?) -> Void) { - if unitsPerHour > state.maxBasalRateUnitsPerHour { - /// The app is trying to set a TBR above the configured max basal. - /// This might happen if the app isn't properly sync'ing its max - /// basal rate value to the Pump Manager in certain situations. - /// Rather than returning an invalidSetting error that will cause Trio - /// to get into a tizzy and stop looping, just log a debug message - /// to note this condition for debugging purposes and continue on. - //completion(.configuration(OmniPumpManagerError.invalidSetting)) - log.error("@@@ enactTempBasal requested unitsPerHour %{public}@ exceeds configured maxBasal of %{public}@!", + /// The app is trying to set a TBR above the configured max basal. This can happen if the + /// app isn't properly sync'ing its max basal rate to the Pump Manager. Rather than returning + /// an invalidSetting error that could stop looping, log and continue (loopandlearn #85 + /// workaround for mismatched basal limits). + log.error("@@@ runTemporaryBasalProgram requested unitsPerHour %{public}@ exceeds configured maxBasal of %{public}@!", String(describing: unitsPerHour), String(describing: state.maxBasalRateUnitsPerHour)) } @@ -3052,6 +3090,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? { @@ -3133,7 +3175,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 @@ -3142,7 +3184,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 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: 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) } } 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 +```