Преглед изворни кода

Merge remote-tracking branch 'ivalkou/dev' into Crowdin

Jon B.M пре 4 година
родитељ
комит
d5c5f12df1
48 измењених фајлова са 2203 додато и 609 уклоњено
  1. 1 1
      Dependencies/rileylink_ios/Common/IdentifiableClass.swift
  2. 9 1
      Dependencies/rileylink_ios/Common/NumberFormatter.swift
  3. 48 1
      Dependencies/rileylink_ios/MinimedKit/PumpManager/MinimedPumpManager.swift
  4. 8 0
      Dependencies/rileylink_ios/MinimedKit/PumpManager/MinimedPumpManagerState.swift
  5. 1 1
      Dependencies/rileylink_ios/MinimedKit/PumpManager/PumpMessageSender.swift
  6. 1 1
      Dependencies/rileylink_ios/MinimedKit/PumpManager/PumpOps.swift
  7. 1 1
      Dependencies/rileylink_ios/MinimedKit/PumpManager/RileyLinkDevice.swift
  8. 1 1
      Dependencies/rileylink_ios/MinimedKitTests/PumpOpsSynchronousTests.swift
  9. 0 20
      Dependencies/rileylink_ios/MinimedKitUI/CommandResponseViewController.swift
  10. 10 2
      Dependencies/rileylink_ios/MinimedKitUI/MinimedPumpSettingsViewController.swift
  11. 3 2
      Dependencies/rileylink_ios/MinimedKitUI/RileyLinkMinimedDeviceTableViewController.swift
  12. 9 12
      Dependencies/rileylink_ios/OmniKit/MessageTransport/MessageBlocks/DetailedStatus.swift
  13. 3 3
      Dependencies/rileylink_ios/OmniKit/MessageTransport/MessageBlocks/StatusResponse.swift
  14. 68 29
      Dependencies/rileylink_ios/OmniKit/MessageTransport/MessageBlocks/VersionResponse.swift
  15. 2 2
      Dependencies/rileylink_ios/OmniKit/MessageTransport/MessageTransport.swift
  16. 83 5
      Dependencies/rileylink_ios/OmniKit/Model/AlertSlot.swift
  17. 12 6
      Dependencies/rileylink_ios/OmniKit/Model/Pod.swift
  18. 1 5
      Dependencies/rileylink_ios/OmniKit/Model/PodProgressStatus.swift
  19. 118 70
      Dependencies/rileylink_ios/OmniKit/PumpManager/OmnipodPumpManager.swift
  20. 15 15
      Dependencies/rileylink_ios/OmniKit/PumpManager/OmnipodPumpManagerState.swift
  21. 57 12
      Dependencies/rileylink_ios/OmniKit/PumpManager/PodComms.swift
  22. 112 19
      Dependencies/rileylink_ios/OmniKit/PumpManager/PodCommsSession.swift
  23. 15 4
      Dependencies/rileylink_ios/OmniKit/PumpManager/PodState.swift
  24. 40 4
      Dependencies/rileylink_ios/OmniKitTests/MessageTests.swift
  25. 16 16
      Dependencies/rileylink_ios/OmniKitTests/PodInfoTests.swift
  26. 1 1
      Dependencies/rileylink_ios/OmniKitTests/StatusTests.swift
  27. 4 4
      Dependencies/rileylink_ios/OmniKitUI/ViewControllers/CommandResponseViewController.swift
  28. 14 6
      Dependencies/rileylink_ios/OmniKitUI/ViewControllers/InsertCannulaSetupViewController.swift
  29. 233 181
      Dependencies/rileylink_ios/OmniKitUI/ViewControllers/OmnipodSettingsViewController.swift
  30. 15 6
      Dependencies/rileylink_ios/OmniKitUI/ViewControllers/PairPodSetupViewController.swift
  31. 7 0
      Dependencies/rileylink_ios/OmniKitUI/ViewControllers/ReplacePodViewController.swift
  32. 4 0
      Dependencies/rileylink_ios/RileyLink.xcodeproj/project.pbxproj
  33. 13 4
      Dependencies/rileylink_ios/RileyLinkBLEKit/CommandSession.swift
  34. 247 16
      Dependencies/rileylink_ios/RileyLinkBLEKit/PeripheralManager+RileyLink.swift
  35. 26 35
      Dependencies/rileylink_ios/RileyLinkBLEKit/PeripheralManager.swift
  36. 16 7
      Dependencies/rileylink_ios/RileyLinkBLEKit/PeripheralManagerError.swift
  37. 2 2
      Dependencies/rileylink_ios/RileyLinkBLEKit/RileyLinkConnectionManager.swift
  38. 228 19
      Dependencies/rileylink_ios/RileyLinkBLEKit/RileyLinkDevice.swift
  39. 6 1
      Dependencies/rileylink_ios/RileyLinkBLEKit/RileyLinkDeviceError.swift
  40. 3 7
      Dependencies/rileylink_ios/RileyLinkKit/PumpOpsSession.swift
  41. 0 16
      Dependencies/rileylink_ios/RileyLinkKit/RileyLinkDevice.swift
  42. 2 0
      Dependencies/rileylink_ios/RileyLinkKit/RileyLinkPumpManager.swift
  43. 63 0
      Dependencies/rileylink_ios/RileyLinkKitUI/Base.lproj/Localizable.strings
  44. 62 0
      Dependencies/rileylink_ios/RileyLinkKitUI/CommandResponseViewController.swift
  45. 555 70
      Dependencies/rileylink_ios/RileyLinkKitUI/RileyLinkDeviceTableViewController.swift
  46. 64 0
      Dependencies/rileylink_ios/RileyLinkKitUI/sv.lproj/Localizable.strings
  47. 3 0
      FreeAPS/Sources/Modules/CGM/View/CGMRootView.swift
  48. 1 1
      scripts/swiftformat.sh

+ 1 - 1
Dependencies/rileylink_ios/Common/IdentifiableClass.swift

@@ -9,7 +9,7 @@
 import Foundation
 
 
-protocol IdentifiableClass: class {
+protocol IdentifiableClass: AnyObject {
     static var className: String { get }
 }
 

+ 9 - 1
Dependencies/rileylink_ios/Common/NumberFormatter.swift

@@ -15,7 +15,15 @@ extension NumberFormatter {
             return nil
         }
     }
-    
+
+    func percentString(from percent: Int?) -> String? {
+        if let percent = percent, let formatted = string(from: NSNumber(value: percent)) {
+            return String(format: LocalizedString("%@%%", comment: "Unit format string for an value in percent"), formatted)
+        } else {
+            return nil
+        }
+    }
+
     func string(from number: Double) -> String? {
         return string(from: NSNumber(value: number))
     }

+ 48 - 1
Dependencies/rileylink_ios/MinimedKit/PumpManager/MinimedPumpManager.swift

@@ -11,7 +11,7 @@ import RileyLinkKit
 import RileyLinkBLEKit
 import os.log
 
-public protocol MinimedPumpManagerStateObserver: class {
+public protocol MinimedPumpManagerStateObserver: AnyObject {
     func didUpdatePumpManagerState(_ state: MinimedPumpManagerState)
 }
 
@@ -207,6 +207,43 @@ public class MinimedPumpManager: RileyLinkPumpManager {
         }
     }
 
+    public var rileyLinkBatteryAlertLevel: Int? {
+        get {
+            return state.rileyLinkBatteryAlertLevel
+        }
+        set {
+            setState { state in
+                state.rileyLinkBatteryAlertLevel = newValue
+            }
+        }
+    }
+    
+    public override func device(_ device: RileyLinkDevice, didUpdateBattery level: Int) {
+        let repeatInterval: TimeInterval = .hours(1)
+        
+        if let alertLevel = state.rileyLinkBatteryAlertLevel,
+           level <= alertLevel,
+           state.lastRileyLinkBatteryAlertDate.addingTimeInterval(repeatInterval) < Date()
+        {
+            self.setState { state in
+                state.lastRileyLinkBatteryAlertDate = Date()
+            }
+            
+            // HACK Alert. This is temporary for the 2.2.5 release. Dev and newer releases will use the new Loop Alert facility
+            let notification = UNMutableNotificationContent()
+            notification.body = String(format: LocalizedString("\"%1$@\" has a low battery", comment: "Format string for low battery alert body for RileyLink. (1: device name)"), device.name ?? "unnamed")
+            notification.title = LocalizedString("Low RileyLink Battery", comment: "Title for RileyLink low battery alert")
+            notification.sound = .default
+            notification.categoryIdentifier = LoopNotificationCategory.loopNotRunning.rawValue
+            notification.threadIdentifier = LoopNotificationCategory.loopNotRunning.rawValue
+            let request = UNNotificationRequest(
+                identifier: "batteryalert.rileylink",
+                content: notification,
+                trigger: nil)
+            UNUserNotificationCenter.current().add(request)
+        }
+    }
+
     // MARK: - CustomDebugStringConvertible
 
     override public var debugDescription: String {
@@ -345,6 +382,8 @@ extension MinimedPumpManager {
         pumpDateComponents.timeZone = timeZone
         glucoseDateComponents?.timeZone = timeZone
 
+        checkRileyLinkBattery()
+
         // The pump sends the same message 3x, so ignore it if we've already seen it.
         guard status != recents.latestPumpStatusFromMySentry, let pumpDate = pumpDateComponents.date else {
             return
@@ -394,6 +433,14 @@ extension MinimedPumpManager {
         }
     }
 
+    private func checkRileyLinkBattery() {
+        rileyLinkDeviceProvider.getDevices { devices in
+            for device in devices {
+                device.updateBatteryLevel()
+            }
+        }
+    }
+
     /**
      Store a new reservoir volume and notify observers of new pump data.
 

Разлика између датотеке није приказан због своје велике величине
+ 8 - 0
Dependencies/rileylink_ios/MinimedKit/PumpManager/MinimedPumpManagerState.swift


+ 1 - 1
Dependencies/rileylink_ios/MinimedKit/PumpManager/PumpMessageSender.swift

@@ -43,7 +43,7 @@ protocol PumpMessageSender {
     func send(_ data: Data, onChannel channel: Int, timeout: TimeInterval) throws
     
     /// - Throws: LocalizedError
-    func enableCCLEDs() throws
+    func setCCLEDMode(_ mode: RileyLinkLEDMode) throws
     
     /// - Throws: LocalizedError
     func getRileyLinkStatistics() throws -> RileyLinkStatistics

+ 1 - 1
Dependencies/rileylink_ios/MinimedKit/PumpManager/PumpOps.swift

@@ -13,7 +13,7 @@ import os.log
 import LoopKit
 
 
-public protocol PumpOpsDelegate: class {
+public protocol PumpOpsDelegate: AnyObject {
     // TODO: Audit clients of this as its called on the session queue
     func pumpOps(_ pumpOps: PumpOps, didChange state: PumpState)
 }

+ 1 - 1
Dependencies/rileylink_ios/MinimedKit/PumpManager/RileyLinkDevice.swift

@@ -16,7 +16,7 @@ extension RileyLinkDevice.Status {
             manufacturer: "Medtronic",
             model: pumpModel.rawValue,
             hardwareVersion: nil,
-            firmwareVersion: radioFirmwareVersion?.description,
+            firmwareVersion: version,
             softwareVersion: String(MinimedKitVersionNumber),
             localIdentifier: pumpID,
             udiDeviceIdentifier: nil

+ 1 - 1
Dependencies/rileylink_ios/MinimedKitTests/PumpOpsSynchronousTests.swift

@@ -377,7 +377,7 @@ class PumpMessageSenderStub: PumpMessageSender {
         throw PumpOpsError.noResponse(during: "Tests")
     }
     
-    func enableCCLEDs() throws {
+    func setCCLEDMode(_ mode: RileyLinkLEDMode) throws {
         throw PumpOpsError.noResponse(during: "Tests")
     }
     

+ 0 - 20
Dependencies/rileylink_ios/MinimedKitUI/CommandResponseViewController.swift

@@ -251,26 +251,6 @@ extension CommandResponseViewController {
         }
     }
 
-    static func enableLEDs(ops: PumpOps?, device: RileyLinkDevice) -> T {
-        return T { (completionHandler) -> String in
-            device.enableBLELEDs()
-            ops?.runSession(withName: "Enable LEDs", using: device) { (session) in
-                let response: String
-                do {
-                    try session.enableCCLEDs()
-                    response = "OK"
-                } catch let error {
-                    response = String(describing: error)
-                }
-
-                DispatchQueue.main.async {
-                    completionHandler(response)
-                }
-            }
-
-            return LocalizedString("Enabled Diagnostic LEDs", comment: "Progress message for enabling diagnostic LEDs")
-        }
-    }
 
     static func readPumpStatus(ops: PumpOps?, device: RileyLinkDevice, measurementFormatter: MeasurementFormatter) -> T {
         return T { (completionHandler) -> String in

+ 10 - 2
Dependencies/rileylink_ios/MinimedKitUI/MinimedPumpSettingsViewController.swift

@@ -272,10 +272,18 @@ class MinimedPumpSettingsViewController: RileyLinkSettingsViewController {
             }
         case .rileyLinks:
             let device = devicesDataSource.devices[indexPath.row]
+            
+            guard device.hardwareType != nil else {
+                tableView.deselectRow(at: indexPath, animated: true)
+                return
+            }
 
-            let vc = RileyLinkMinimedDeviceTableViewController(
+            let vc = RileyLinkDeviceTableViewController(
                 device: device,
-                pumpOps: pumpManager.pumpOps
+                batteryAlertLevel: pumpManager.rileyLinkBatteryAlertLevel,
+                batteryAlertLevelChanged: { [weak self] value in
+                    self?.pumpManager.rileyLinkBatteryAlertLevel = value
+                }
             )
 
             self.show(vc, sender: sender)

+ 3 - 2
Dependencies/rileylink_ios/MinimedKitUI/RileyLinkMinimedDeviceTableViewController.swift

@@ -122,7 +122,7 @@ public class RileyLinkMinimedDeviceTableViewController: UITableViewController {
         device.getStatus { (status) in
             DispatchQueue.main.async {
                 self.lastIdle = status.lastIdle
-                self.firmwareVersion = status.firmwareDescription
+                self.firmwareVersion = status.version
             }
         }
     }
@@ -460,7 +460,8 @@ public class RileyLinkMinimedDeviceTableViewController: UITableViewController {
             case .readBasalSchedule:
                 vc = .readBasalSchedule(ops: ops, device: device, integerFormatter: integerFormatter)
             case .enableLED:
-                vc = .enableLEDs(ops: ops, device: device)
+//                vc = .enableLEDs(ops: ops, device: device)
+                vc = .getStatistics(ops: ops, device: device)
             case .discoverCommands:
                 vc = .discoverCommands(ops: ops, device: device)
             case .getStatistics:

+ 9 - 12
Dependencies/rileylink_ios/OmniKit/MessageTransport/MessageBlocks/DetailedStatus.swift

@@ -19,7 +19,7 @@ public struct DetailedStatus : PodInfo, Equatable {
     public let podProgressStatus: PodProgressStatus
     public let deliveryStatus: DeliveryStatus
     public let bolusNotDelivered: Double
-    public let podMessageCounter: UInt8
+    public let lastProgrammingMessageSeqNum: UInt8 // updated by pod for 03, 08, $11, $19, $1A, $1C, $1E & $1F command messages
     public let totalInsulinDelivered: Double
     public let faultEventCode: FaultEventCode
     public let faultEventTimeSinceActivation: TimeInterval?
@@ -31,7 +31,7 @@ public struct DetailedStatus : PodInfo, Equatable {
     public let receiverLowGain: UInt8
     public let radioRSSI: UInt8
     public let previousPodProgressStatus: PodProgressStatus?
-    public let unknownValue: Data
+    // YYYY is uninitialized data for Eros
     public let data: Data
     
     public init(encodedData: Data) throws {
@@ -48,7 +48,7 @@ public struct DetailedStatus : PodInfo, Equatable {
         
         self.bolusNotDelivered = Pod.pulseSize * Double((Int(encodedData[3] & 0x3) << 8) | Int(encodedData[4]))
         
-        self.podMessageCounter = encodedData[5]
+        self.lastProgrammingMessageSeqNum = encodedData[5]
         
         self.totalInsulinDelivered = Pod.pulseSize * Double(encodedData[6...7].toBigEndian(UInt16.self))
         
@@ -73,7 +73,7 @@ public struct DetailedStatus : PodInfo, Equatable {
         
         self.unacknowledgedAlerts =  AlertSet(rawValue: encodedData[15])
         
-        self.faultAccessingTables = encodedData[16] == 2
+        self.faultAccessingTables = (encodedData[16] & 2) != 0
         
         if encodedData[17] == 0x00 {
            self.errorEventInfo = nil // this byte is not valid (no fault has occurred)
@@ -90,8 +90,6 @@ public struct DetailedStatus : PodInfo, Equatable {
             self.previousPodProgressStatus = PodProgressStatus(rawValue: encodedData[19] & 0xF)!
         }
         
-        self.unknownValue = encodedData[20...21]
-        
         self.data = Data(encodedData)
     }
 
@@ -145,7 +143,7 @@ extension DetailedStatus: CustomDebugStringConvertible {
             "* podProgressStatus: \(podProgressStatus)",
             "* deliveryStatus: \(deliveryStatus.description)",
             "* bolusNotDelivered: \(bolusNotDelivered.twoDecimals) U",
-            "* podMessageCounter: \(podMessageCounter)",
+            "* lastProgrammingMessageSeqNum: \(lastProgrammingMessageSeqNum)",
             "* totalInsulinDelivered: \(totalInsulinDelivered.twoDecimals) U",
             "* faultEventCode: \(faultEventCode.description)",
             "* faultEventTimeSinceActivation: \(faultEventTimeSinceActivation?.stringValue ?? "none")",
@@ -157,7 +155,6 @@ extension DetailedStatus: CustomDebugStringConvertible {
             "* receiverLowGain: \(receiverLowGain)",
             "* radioRSSI: \(radioRSSI)",
             "* previousPodProgressStatus: \(previousPodProgressStatus?.description ?? "NA")",
-            "* unknownValue: 0x\(unknownValue.hexadecimalString)",
             "",
             ].joined(separator: "\n")
     }
@@ -205,14 +202,14 @@ extension Double {
 
 // Type for the ErrorEventInfo VV byte if valid
 //    a: insulin state table corruption found during error logging
-//   bb: internal 2-bit variable set and manipulated in main loop routines
+//   bb: internal 2-bit occlusion type
 //    c: immediate bolus in progress during error
 // dddd: Pod Progress at time of first logged fault event
 //
 public struct ErrorEventInfo: CustomStringConvertible, Equatable {
     let rawValue: UInt8
     let insulinStateTableCorruption: Bool // 'a' bit
-    let internalVariable: Int // 'bb' 2-bit internal variable
+    let occlusionType: Int // 'bb' 2-bit occlusion type
     let immediateBolusInProgress: Bool // 'c' bit
     let podProgressStatus: PodProgressStatus // 'dddd' bits
 
@@ -225,7 +222,7 @@ public struct ErrorEventInfo: CustomStringConvertible, Equatable {
         return [
             "rawValue: 0x\(hexString)",
             "insulinStateTableCorruption: \(insulinStateTableCorruption)",
-            "internalVariable: \(internalVariable)",
+            "occlusionType: \(occlusionType)",
             "immediateBolusInProgress: \(immediateBolusInProgress)",
             "podProgressStatus: \(podProgressStatus)",
             ].joined(separator: ", ")
@@ -234,7 +231,7 @@ public struct ErrorEventInfo: CustomStringConvertible, Equatable {
     init(rawValue: UInt8)  {
         self.rawValue = rawValue
         self.insulinStateTableCorruption = (rawValue & 0x80) != 0
-        self.internalVariable = Int((rawValue & 0x60) >> 5)
+        self.occlusionType = Int((rawValue & 0x60) >> 5)
         self.immediateBolusInProgress = (rawValue & 0x10) != 0
         self.podProgressStatus = PodProgressStatus(rawValue: rawValue & 0xF)!
     }

+ 3 - 3
Dependencies/rileylink_ios/OmniKit/MessageTransport/MessageBlocks/StatusResponse.swift

@@ -17,7 +17,7 @@ public struct StatusResponse : MessageBlock {
     public let reservoirLevel: Double?
     public let insulin: Double
     public let bolusNotDelivered: Double
-    public let podMessageCounter: UInt8
+    public let lastProgrammingMessageSeqNum: UInt8 // updated by pod for 03, 08, $11, $19, $1A, $1C, $1E & $1F command messages
     public let alerts: AlertSet
     
     
@@ -48,7 +48,7 @@ public struct StatusResponse : MessageBlock {
         let lowInsulinBits = Int(encodedData[4] >> 7)
         self.insulin = Double(highInsulinBits | midInsulinBits | lowInsulinBits) / Pod.pulsesPerUnit
         
-        self.podMessageCounter = (encodedData[4] >> 3) & 0xf
+        self.lastProgrammingMessageSeqNum = (encodedData[4] >> 3) & 0xf
         
         self.bolusNotDelivered = Double((Int(encodedData[4] & 0x3) << 8) | Int(encodedData[5])) / Pod.pulsesPerUnit
 
@@ -65,7 +65,7 @@ public struct StatusResponse : MessageBlock {
 
 extension StatusResponse: CustomDebugStringConvertible {
     public var debugDescription: String {
-        return "StatusResponse(deliveryStatus:\(deliveryStatus), progressStatus:\(podProgressStatus), timeActive:\(timeActive.stringValue), reservoirLevel:\(String(describing: reservoirLevel)), delivered:\(insulin), bolusNotDelivered:\(bolusNotDelivered), seq:\(podMessageCounter), alerts:\(alerts))"
+        return "StatusResponse(deliveryStatus:\(deliveryStatus), progressStatus:\(podProgressStatus), timeActive:\(timeActive.stringValue), reservoirLevel:\(String(describing: reservoirLevel)), delivered:\(insulin), bolusNotDelivered:\(bolusNotDelivered), lastProgrammingMessageSeqNum:\(lastProgrammingMessageSeqNum), alerts:\(alerts))"
     }
 }
 

Разлика између датотеке није приказан због своје велике величине
+ 68 - 29
Dependencies/rileylink_ios/OmniKit/MessageTransport/MessageBlocks/VersionResponse.swift


+ 2 - 2
Dependencies/rileylink_ios/OmniKit/MessageTransport/MessageTransport.swift

@@ -11,7 +11,7 @@ import os.log
 
 import RileyLinkBLEKit
 
-protocol MessageLogger: class {
+protocol MessageLogger: AnyObject {
     // Comms logging
     func didSend(_ message: Data)
     func didReceive(_ message: Data)
@@ -49,7 +49,7 @@ public struct MessageTransportState: Equatable, RawRepresentable {
 
 }
 
-protocol MessageTransportDelegate: class {
+protocol MessageTransportDelegate: AnyObject {
     func messageTransport(_ messageTransport: MessageTransport, didUpdate state: MessageTransportState)
 }
 

+ 83 - 5
Dependencies/rileylink_ios/OmniKit/Model/AlertSlot.swift

@@ -81,23 +81,33 @@ public enum PodAlert: CustomStringConvertible, RawRepresentable, Equatable {
     // auto-off timer; requires user input every x minutes
     case autoOffAlarm(active: Bool, countdownDuration: TimeInterval)
 
+    // pod suspended reminder, before suspendTime; short beep every 15 minutes if >= 30 min, else every 5 minutes
+    case podSuspendedReminder(active: Bool, suspendTime: TimeInterval)
+
+    // pod suspend time expired alarm, after suspendTime; 2 sets of beeps every min for 3 minutes repeated every 15 minutes
+    case suspendTimeExpired(suspendTime: TimeInterval)
+
     public var description: String {
         var alertName: String
         switch self {
         case .waitingForPairingReminder:
             return LocalizedString("Waiting for pairing reminder", comment: "Description waiting for pairing reminder")
         case .finishSetupReminder:
-            return LocalizedString("Finish setup ", comment: "Description for finish setup")
+            return LocalizedString("Finish setup reminder", comment: "Description for finish setup reminder")
         case .expirationAlert:
             alertName = LocalizedString("Expiration alert", comment: "Description for expiration alert")
         case .expirationAdvisoryAlarm:
-            alertName = LocalizedString("Pod expiration advisory alarm", comment: "Description for expiration advisory alarm")
+            alertName = LocalizedString("Expiration advisory", comment: "Description for expiration advisory")
         case .shutdownImminentAlarm:
-            alertName = LocalizedString("Shutdown imminent alarm", comment: "Description for shutdown imminent alarm")
+            alertName = LocalizedString("Shutdown imminent", comment: "Description for shutdown imminent")
         case .lowReservoirAlarm:
-            alertName = LocalizedString("Low reservoir advisory alarm", comment: "Description for low reservoir alarm")
+            alertName = LocalizedString("Low reservoir advisory", comment: "Description for low reservoir advisory")
         case .autoOffAlarm:
-            alertName = LocalizedString("Auto-off alarm", comment: "Description for auto-off alarm")
+            alertName = LocalizedString("Auto-off", comment: "Description for auto-off")
+        case .podSuspendedReminder:
+            alertName = LocalizedString("Pod suspended reminder", comment: "Description for pod suspended reminder")
+        case .suspendTimeExpired:
+            alertName = LocalizedString("Suspend time expired", comment: "Description for suspend time expired")
         }
         if self.configuration.active == false {
             alertName += LocalizedString(" (inactive)", comment: "Description for an inactive alert modifier")
@@ -125,6 +135,53 @@ public enum PodAlert: CustomStringConvertible, RawRepresentable, Equatable {
             return AlertConfiguration(alertType: .slot4, active: active, duration: 0, trigger: .unitsRemaining(units), beepRepeat: .every1MinuteFor3MinutesAndRepeatEvery60Minutes, beepType: .bipBeepBipBeepBipBeepBipBeep)
         case .autoOffAlarm(let active, let countdownDuration):
             return AlertConfiguration(alertType: .slot0, active: active, autoOffModifier: true, duration: .minutes(15), trigger: .timeUntilAlert(countdownDuration), beepRepeat: .every1MinuteFor15Minutes, beepType: .bipBeepBipBeepBipBeepBipBeep)
+        case .podSuspendedReminder(let active, let suspendTime):
+            // A suspendTime of 0 is an untimed suspend
+            let reminderInterval, duration: TimeInterval
+            let trigger: AlertTrigger
+            let beepRepeat: BeepRepeat
+            let beepType: BeepType
+            if active {
+                if suspendTime >= TimeInterval(minutes :30) {
+                    // Use 15-minute pod suspended reminder beeps for longer scheduled suspend times as per PDM.
+                    reminderInterval = TimeInterval(minutes: 15)
+                    beepRepeat = .every15Minutes
+                } else {
+                    // Use 5-minute pod suspended reminder beeps for untimed & shorter scheduled suspend times.
+                    reminderInterval = TimeInterval(minutes: 5)
+                    beepRepeat = .every5Minutes
+                }
+                if suspendTime == 0 {
+                    duration = 0 // Untimed suspend, no duration
+                } else if suspendTime > reminderInterval {
+                    duration = suspendTime - reminderInterval // End after suspendTime total time
+                } else {
+                    duration = .minutes(1) // Degenerate case, end ASAP
+                }
+                trigger = .timeUntilAlert(reminderInterval) // Start after reminderInterval has passed
+                beepType = .beep
+            } else {
+                duration = 0
+                trigger = .timeUntilAlert(.minutes(0))
+                beepRepeat = .once
+                beepType = .noBeep
+            }
+            return AlertConfiguration(alertType: .slot5, active: active, duration: duration, trigger: trigger, beepRepeat: beepRepeat, beepType: beepType)
+        case .suspendTimeExpired(let suspendTime):
+            let active = suspendTime != 0 // disable if suspendTime is 0
+            let trigger: AlertTrigger
+            let beepRepeat: BeepRepeat
+            let beepType: BeepType
+            if active {
+                trigger = .timeUntilAlert(suspendTime)
+                beepRepeat = .every1MinuteFor3MinutesAndRepeatEvery15Minutes
+                beepType = .bipBeepBipBeepBipBeepBipBeep
+            } else {
+                trigger = .timeUntilAlert(.minutes(0))
+                beepRepeat = .once
+                beepType = .noBeep
+            }
+            return AlertConfiguration(alertType: .slot6, active: active, duration: 0, trigger: trigger, beepRepeat: beepRepeat, beepType: beepType)
         }
     }
 
@@ -170,6 +227,18 @@ public enum PodAlert: CustomStringConvertible, RawRepresentable, Equatable {
                 return nil
             }
             self = .autoOffAlarm(active: active, countdownDuration: TimeInterval(countdownDuration))
+        case "podSuspendedReminder":
+            guard let active = rawValue["active"] as? Bool,
+                let suspendTime = rawValue["suspendTime"] as? Double else
+            {
+                return nil
+            }
+            self = .podSuspendedReminder(active: active, suspendTime: suspendTime)
+        case "suspendTimeExpired":
+            guard let suspendTime = rawValue["suspendTime"] as? Double else {
+                return nil
+            }
+            self = .suspendTimeExpired(suspendTime: suspendTime)
         default:
             return nil
         }
@@ -193,6 +262,10 @@ public enum PodAlert: CustomStringConvertible, RawRepresentable, Equatable {
                 return "lowReservoirAlarm"
             case .autoOffAlarm:
                 return "autoOffAlarm"
+            case .podSuspendedReminder:
+                return "podSuspendedReminder"
+            case .suspendTimeExpired:
+                return "suspendTimeExpired"
             }
         }()
 
@@ -214,6 +287,11 @@ public enum PodAlert: CustomStringConvertible, RawRepresentable, Equatable {
         case .autoOffAlarm(let active, let countdownDuration):
             rawValue["active"] = active
             rawValue["countdownDuration"] = countdownDuration
+        case .podSuspendedReminder(let active, let suspendTime):
+            rawValue["active"] = active
+            rawValue["suspendTime"] = suspendTime
+        case .suspendTimeExpired(let suspendTime):
+            rawValue["suspendTime"] = suspendTime
         default:
             break
         }

+ 12 - 6
Dependencies/rileylink_ios/OmniKit/Model/Pod.swift

@@ -9,19 +9,22 @@
 import Foundation
 
 public struct Pod {
-    // Volume of insulin in one motor pulse
+    // Volume of U100 insulin in one motor pulse
+    // Must agree with value returned by pod during the pairing process.
     public static let pulseSize: Double = 0.05
 
-    // Number of pulses required to deliver one unit of insulin
+    // Number of pulses required to deliver one unit of U100 insulin
     public static let pulsesPerUnit: Double = 1 / Pod.pulseSize
 
     // Seconds per pulse for boluses
+    // Checked to verify it agrees with value returned by pod during the pairing process.
     public static let secondsPerBolusPulse: Double = 2
 
     // Units per second for boluses
     public static let bolusDeliveryRate: Double = Pod.pulseSize / Pod.secondsPerBolusPulse
 
     // Seconds per pulse for priming/cannula insertion
+    // Checked to verify it agrees with value returned by pod during the pairing process.
     public static let secondsPerPrimePulse: Double = 1
 
     // Units per second for priming/cannula insertion
@@ -37,6 +40,7 @@ public struct Pod {
     public static let endOfServiceImminentWindow = TimeInterval(hours: 1)
 
     // Total pod service time. A fault is triggered if this time is reached before pod deactivation.
+    // Checked to verify it agrees with value returned by pod during the pairing process.
     public static let serviceDuration = TimeInterval(hours: 80)
 
     // Nomimal pod life (72 hours)
@@ -57,13 +61,15 @@ public struct Pod {
     // Minimum duration of a single basal schedule entry
     public static let minimumBasalScheduleEntryDuration = TimeInterval.minutes(30)
 
-    // Amount of insulin delivered with 1 second between pulses for priming
+    // Default amount for priming bolus using secondsPerPrimePulse timing.
+    // Checked to verify it agrees with value returned by pod during the pairing process.
     public static let primeUnits = 2.6
 
-    // Amount of insulin delivered with 1 second between pulses for cannula insertion
-    public static let cannulaInsertionUnitsBase = 0.5
+    // Default amount for cannula insertion bolus using secondsPerPrimePulse timing.
+    // Checked to verify it agrees with value returned by pod during the pairing process.
+    public static let cannulaInsertionUnits = 0.5
+
     public static let cannulaInsertionUnitsExtra = 0.0 // edit to add a fixed additional amount of insulin during cannula insertion
-    public static let cannulaInsertionUnits = cannulaInsertionUnitsBase + cannulaInsertionUnitsExtra
 
     // Default and limits for expiration reminder alerts
     public static let expirationReminderAlertDefaultTimeBeforeExpiration = TimeInterval.hours(2)

+ 1 - 5
Dependencies/rileylink_ios/OmniKit/Model/PodProgressStatus.swift

@@ -30,10 +30,6 @@ public enum PodProgressStatus: UInt8, CustomStringConvertible, Equatable {
         return self == .fiftyOrLessUnits || self == .aboveFiftyUnits
     }
     
-    public var unfinishedPairing: Bool {
-        return self.rawValue < PodProgressStatus.aboveFiftyUnits.rawValue
-    }
-
     public var description: String {
         switch self {
         case .initialized:
@@ -43,7 +39,7 @@ public enum PodProgressStatus: UInt8, CustomStringConvertible, Equatable {
         case .reminderInitialized:
             return LocalizedString("Reminder initialized", comment: "Pod pairing reminder initialized")
         case .pairingCompleted:
-            return LocalizedString("Paired completed", comment: "Pod status when pairing completed")
+            return LocalizedString("Pairing completed", comment: "Pod status when pairing completed")
         case .priming:
             return LocalizedString("Priming", comment: "Pod status when priming")
         case .primingCompleted:

+ 118 - 70
Dependencies/rileylink_ios/OmniKit/PumpManager/OmnipodPumpManager.swift

@@ -20,15 +20,13 @@ public enum ReservoirAlertState {
     case empty
 }
 
-public protocol PodStateObserver: class {
+public protocol PodStateObserver: AnyObject {
     func podStateDidUpdate(_ state: PodState?)
 }
 
 public enum OmnipodPumpManagerError: Error {
     case noPodPaired
     case podAlreadyPaired
-    case podAlreadyPrimed
-    case notReadyForPrime
     case notReadyForCannulaInsertion
 }
 
@@ -37,12 +35,8 @@ extension OmnipodPumpManagerError: LocalizedError {
         switch self {
         case .noPodPaired:
             return LocalizedString("No pod paired", comment: "Error message shown when no pod is paired")
-        case .podAlreadyPrimed:
-            return LocalizedString("Pod already primed", comment: "Error message shown when prime is attempted, but pod is already primed")
         case .podAlreadyPaired:
             return LocalizedString("Pod already paired", comment: "Error message shown when user cannot pair because pod is already paired")
-        case .notReadyForPrime:
-            return LocalizedString("Pod is not in a state ready for priming.", comment: "Error message when prime fails because the pod is in an unexpected state")
         case .notReadyForCannulaInsertion:
             return LocalizedString("Pod is not in a state ready for cannula insertion.", comment: "Error message when cannula insertion fails because the pod is in an unexpected state")
         }
@@ -52,12 +46,8 @@ extension OmnipodPumpManagerError: LocalizedError {
         switch self {
         case .noPodPaired:
             return nil
-        case .podAlreadyPrimed:
-            return nil
         case .podAlreadyPaired:
             return nil
-        case .notReadyForPrime:
-            return nil
         case .notReadyForCannulaInsertion:
             return nil
         }
@@ -67,12 +57,8 @@ extension OmnipodPumpManagerError: LocalizedError {
         switch self {
         case .noPodPaired:
             return LocalizedString("Please pair a new pod", comment: "Recover suggestion shown when no pod is paired")
-        case .podAlreadyPrimed:
-            return nil
         case .podAlreadyPaired:
             return nil
-        case .notReadyForPrime:
-            return nil
         case .notReadyForCannulaInsertion:
             return nil
         }
@@ -229,6 +215,43 @@ public class OmnipodPumpManager: RileyLinkPumpManager {
         }
     }
 
+    public var rileyLinkBatteryAlertLevel: Int? {
+        get {
+            return state.rileyLinkBatteryAlertLevel
+        }
+        set {
+            setState { state in
+                state.rileyLinkBatteryAlertLevel = newValue
+            }
+        }
+    }
+
+    public override func device(_ device: RileyLinkDevice, didUpdateBattery level: Int) {
+        let repeatInterval: TimeInterval = .hours(1)
+
+        if let alertLevel = state.rileyLinkBatteryAlertLevel,
+           level <= alertLevel,
+           state.lastRileyLinkBatteryAlertDate.addingTimeInterval(repeatInterval) < Date()
+        {
+            self.setState { state in
+                state.lastRileyLinkBatteryAlertDate = Date()
+            }
+
+            // HACK Alert. This is temporary for the v2.2.5 & v2.2.6 releases. Dev and newer releases will use the new Loop Alert facility
+            let notification = UNMutableNotificationContent()
+            notification.body = String(format: LocalizedString("\"%1$@\" has a low battery", comment: "Format string for low battery alert body for RileyLink. (1: device name)"), device.name ?? "unnamed")
+            notification.title = LocalizedString("Low RileyLink Battery", comment: "Title for RileyLink low battery alert")
+            notification.sound = .default
+            notification.categoryIdentifier = LoopNotificationCategory.loopNotRunning.rawValue
+            notification.threadIdentifier = LoopNotificationCategory.loopNotRunning.rawValue
+            let request = UNNotificationRequest(
+                identifier: "batteryalert.rileylink",
+                content: notification,
+                trigger: nil)
+            UNUserNotificationCenter.current().add(request)
+        }
+    }
+
     // MARK: - CustomDebugStringConvertible
 
     override public var debugDescription: String {
@@ -406,7 +429,7 @@ extension OmnipodPumpManager {
         
         return nil
     }
-        
+
 
     // Thread-safe
     public var hasActivePod: Bool {
@@ -544,7 +567,7 @@ extension OmnipodPumpManager {
     private func jumpStartPod(address: UInt32, lot: UInt32, tid: UInt32, fault: DetailedStatus? = nil, startDate: Date? = nil, mockFault: Bool) {
         let start = startDate ?? Date()
         var podState = PodState(address: address, piVersion: "jumpstarted", pmVersion: "jumpstarted", lot: lot, tid: tid, insulinType: .novolog)
-        podState.setupProgress = .podConfigured
+        podState.setupProgress = .completed
         podState.activatedAt = start
         podState.expiresAt = start + .hours(72)
         
@@ -581,7 +604,8 @@ extension OmnipodPumpManager {
                 return state.podState?.fault
             })
             if mockFaultDuringPairing {
-                completion(.failure(PumpManagerError.deviceState(PodCommsError.podFault(fault: fault!))))
+                // needs to be PumpManagerError.communication for OmniKitUI error checking to work correctly
+                completion(.failure(PumpManagerError.communication(PodCommsError.podFault(fault: fault!))))
             } else if mockCommsErrorDuringPairing {
                 completion(.failure(PumpManagerError.communication(PodCommsError.noResponse)))
             } else {
@@ -591,13 +615,13 @@ extension OmnipodPumpManager {
         }
         #else
         let deviceSelector = self.rileyLinkDeviceProvider.firstConnectedDevice
-        let configureAndPrimeSession = { (result: PodComms.SessionRunResult) in
+        let primeSession = { (result: PodComms.SessionRunResult) in
             switch result {
             case .success(let session):
                 // We're on the session queue
                 session.assertOnSessionQueue()
 
-                self.log.default("Beginning pod configuration and prime")
+                self.log.default("Beginning pod prime")
 
                 // Clean up any previously un-stored doses if needed
                 let unstoredDoses = self.state.unstoredDoses
@@ -618,21 +642,16 @@ extension OmnipodPumpManager {
             }
         }
 
-        let needsPairing = setStateWithResult({ (state) -> PumpManagerResult<Bool> in
+        let needsPairing = setStateWithResult({ (state) -> Bool in
             guard let podState = state.podState else {
-                return .success(true) // Needs pairing
+                return true // Needs pairing
             }
 
-            guard podState.setupProgress.primingNeeded else {
-                return .failure(PumpManagerError.deviceState(OmnipodPumpManagerError.podAlreadyPrimed))
-            }
-
-            // If still need configuring, run pair()
-            return .success(podState.setupProgress == .addressAssigned)
+            // Return true if not yet paired
+            return podState.setupProgress.isPaired == false
         })
 
-        switch needsPairing {
-        case .success(true):
+        if needsPairing {
             self.log.default("Pairing pod before priming")
             
             // Create random address with 20 bits to match PDM, could easily use 24 bits instead
@@ -657,17 +676,15 @@ extension OmnipodPumpManager {
                 }
                 
                 // Calls completion
-                configureAndPrimeSession(result)
+                primeSession(result)
             }
-        case .success(false):
+        } else {
             self.log.default("Pod already paired. Continuing.")
 
-            self.podComms.runSession(withName: "Configure and prime pod", using: deviceSelector) { (result) in
+            self.podComms.runSession(withName: "Prime pod", using: deviceSelector) { (result) in
                 // Calls completion
-                configureAndPrimeSession(result)
+                primeSession(result)
             }
-        case .failure(let error):
-            completion(.failure(error))
         }
         #endif
     }
@@ -682,7 +699,7 @@ extension OmnipodPumpManager {
                 // Mock fault
                 //            let fault = try! DetailedStatus(encodedData: Data(hexadecimalString: "020d0000000e00c36a020703ff020900002899080082")!)
                 //            self.state.podState?.fault = fault
-                //            return .failure(PodCommsError.podFault(fault: fault))
+                //            return .failure(PumpManagerError.communication(PodCommsError.podFault(fault: fault)))
 
                 // Mock success
                 state.podState?.setupProgress = .completed
@@ -759,7 +776,7 @@ extension OmnipodPumpManager {
         }
     }
 
-    public func refreshStatus(emitConfirmationBeep: Bool, completion: ((_ result: PumpManagerResult<StatusResponse>) -> Void)? = nil) {
+    public func refreshStatus(emitConfirmationBeep: Bool = false, completion: ((_ result: PumpManagerResult<StatusResponse>) -> Void)? = nil) {
         guard self.hasActivePod else {
             completion?(.failure(.deviceState(OmnipodPumpManagerError.noPodPaired)))
             return
@@ -977,7 +994,7 @@ extension OmnipodPumpManager {
     public func readPodStatus(completion: @escaping (Result<DetailedStatus, Error>) -> Void) {
         // use hasSetupPod to be able to read pod info from a faulted Pod
         guard self.hasSetupPod else {
-            completion(.failure(PodCommsError.noPodPaired))
+            completion(.failure(OmnipodPumpManagerError.noPodPaired))
             return
         }
 
@@ -1042,7 +1059,6 @@ extension OmnipodPumpManager {
             case .success(let session):
                 let beep = self.confirmationBeeps
                 let result = session.beepConfig(beepConfigType: .bipBeepBipBeepBipBeepBipBeep, basalCompletionBeep: beep, tempBasalCompletionBeep: false, bolusCompletionBeep: beep)
-                
                 switch result {
                 case .success:
                     completion(nil)
@@ -1058,7 +1074,7 @@ extension OmnipodPumpManager {
     public func readPulseLog(completion: @escaping (Result<String, Error>) -> Void) {
         // use hasSetupPod to be able to read the pulse log from a faulted Pod
         guard self.hasSetupPod else {
-            completion(.failure(PodCommsError.noPodPaired))
+            completion(.failure(OmnipodPumpManagerError.noPodPaired))
             return
         }
         guard state.podState?.isFaulted == true || state.podState?.unfinalizedBolus?.scheduledCertainty == .uncertain || state.podState?.unfinalizedBolus?.isFinished != false else
@@ -1222,6 +1238,12 @@ extension OmnipodPumpManager: PumpManager {
     // MARK: Methods
 
     public func suspendDelivery(completion: @escaping (Error?) -> Void) {
+        suspendDelivery(withSuspendReminders: 0, completion: completion) // untimed with suspend reminder beeps
+    }
+
+    // A nil suspendReminder is untimed with no reminders beeps, a suspendReminder of 0 is untimed using reminders beeps, otherwise it
+    // specifies a suspend duration implemented using an appropriate combination of suspended reminder and suspend time expired beeps.
+    public func suspendDelivery(withSuspendReminders suspendReminder: TimeInterval? = nil, completion: @escaping (Error?) -> Void) {
         guard self.hasActivePod else {
             completion(OmnipodPumpManagerError.noPodPaired)
             return
@@ -1248,9 +1270,8 @@ extension OmnipodPumpManager: PumpManager {
                 state.suspendEngageState = .engaging
             })
 
-            // use confirmationBeepType here for confirmation beeps to avoid getting 3 beeps!
             let beepType: BeepConfigType? = self.confirmationBeeps ? .beeeeeep : nil
-            let result = session.cancelDelivery(deliveryType: .all, confirmationBeepType: beepType)
+            let result = session.suspendDelivery(suspendReminder: suspendReminder, confirmationBeepType: beepType)
             switch result {
             case .certainFailure(let error):
                 completion(error)
@@ -1296,6 +1317,7 @@ extension OmnipodPumpManager: PumpManager {
                 let scheduleOffset = self.state.timeZone.scheduleOffset(forDate: Date())
                 let beep = self.confirmationBeeps
                 let _ = try session.resumeBasal(schedule: self.state.basalSchedule, scheduleOffset: scheduleOffset, acknowledgementBeep: beep, completionBeep: beep)
+                try session.cancelSuspendAlerts()
                 session.dosesForStorage() { (doses) -> Bool in
                     return self.store(doses: doses, in: session)
                 }
@@ -1335,6 +1357,8 @@ extension OmnipodPumpManager: PumpManager {
             return state.isPumpDataStale
         }
 
+        checkRileyLinkBattery()
+
         switch shouldFetchStatus {
         case .none:
             completion?()
@@ -1362,6 +1386,14 @@ extension OmnipodPumpManager: PumpManager {
         }
     }
 
+    private func checkRileyLinkBattery() {
+        rileyLinkDeviceProvider.getDevices { devices in
+            for device in devices {
+                device.updateBatteryLevel()
+            }
+        }
+    }
+
     public func enactBolus(units: Double, automatic: Bool, completion: @escaping (PumpManagerResult<DoseEntry>) -> Void) {
         guard self.hasActivePod else {
             completion(.failure(PumpManagerError.configuration(OmnipodPumpManagerError.noPodPaired)))
@@ -1428,7 +1460,7 @@ extension OmnipodPumpManager: PumpManager {
                     completion(.failure(PumpManagerError.deviceState(PodCommsError.unfinalizedBolus)))
                     return
                 } else if unfinalizedBolus.isBolusPositivelyFinished == false {
-                    self.log.info("enactBolus: doing getStatus to verify bolus completion")
+                    self.log.info("enactBolus: doing getStatus to verify if bolus completed")
                     getStatusNeeded = true
                 } else {
                     finalizeFinishedDosesNeeded = true // call finalizeFinishDoses() to clean up the certain & positively finalized bolus
@@ -1458,7 +1490,7 @@ extension OmnipodPumpManager: PumpManager {
             let acknowledgementBeep = self.confirmationBeeps && (!automatic || self.automaticBolusBeeps)
             let completionBeep = self.confirmationBeeps && !automatic
 
-            // Use an alternate 0x3F TimeInterval for denote an automatic bolus in the Omnipod Communications Log
+            // Use a maximum programReminderInterval value of 0x3F to denote an automatic bolus in the communication log
             let programReminderInterval: TimeInterval = automatic ? TimeInterval(minutes: 0x3F) : 0
 
             let result = session.bolus(units: enactUnits, automatic: automatic, acknowledgementBeep: acknowledgementBeep, completionBeep: completionBeep, programReminderInterval: programReminderInterval)
@@ -1571,32 +1603,48 @@ extension OmnipodPumpManager: PumpManager {
                 return
             }
 
-            let status: StatusResponse
-            let canceledDose: UnfinalizedDose?
+            // resuming a normal basal is denoted by a 0 duration temp basal which simply cancels any existing temp basal
+            let resumingNormalBasal = duration < .ulpOfOne
 
-            let result = session.cancelDelivery(deliveryType: .tempBasal)
-            switch result {
-            case .certainFailure(let error):
-                completion(.failure(PumpManagerError.communication(error)))
-                return
-            case .uncertainFailure(let error):
-                // TODO: Return PumpManagerError.uncertainDelivery and implement recovery
-                completion(.failure(PumpManagerError.communication(error)))
-                return
-            case .success(let cancelTempStatus, let dose):
-                status = cancelTempStatus
-                canceledDose = dose
-            }
+            // Skip the Cancel TB comms optimization if the last message had any
+            // comms issues or if the last delivery status hasn't been verified OK
+            let skipCancelTBCommsOptimization = self.state.podState?.lastCommsOK == false ||
+                self.state.podState?.deliveryStatusVerified == false
 
-            guard !status.deliveryStatus.bolusing else {
-                completion(.failure(PumpManagerError.communication(PodCommsError.unfinalizedBolus)))
-                return
-            }
+            // Do the cancel TB command if we are resuming a normal basal,
+            // we currently have a temp basal running,
+            // or we are skipping the cancel TB comms optimization
+            var canceledDose: UnfinalizedDose? = nil
+            if resumingNormalBasal || self.state.podState?.unfinalizedTempBasal != nil || skipCancelTBCommsOptimization {
+                let status: StatusResponse
 
-            guard status.deliveryStatus != .suspended else {
-                self.log.info("Canceling temp basal because status return indicates pod is suspended.")
-                completion(.failure(PumpManagerError.communication(PodCommsError.podSuspended)))
-                return
+                let result = session.cancelDelivery(deliveryType: .tempBasal, beepType: .noBeep)
+                switch result {
+                case .certainFailure(let error):
+                    completion(.failure(PumpManagerError.deviceState(error)))
+                    return
+                case .uncertainFailure(let error):
+                    // TODO: Return PumpManagerError.uncertainDelivery and implement recovery
+                    completion(.failure(PumpManagerError.deviceState(error)))
+                    return
+                case .success(let cancelTempStatus, let dose):
+                    status = cancelTempStatus
+                    canceledDose = dose
+                }
+
+                guard !status.deliveryStatus.bolusing else {
+                    self.log.info("Canceling temp basal because status return indicates bolus in progress.")
+                    completion(.failure(PumpManagerError.deviceState(PodCommsError.unfinalizedBolus)))
+                    return
+                }
+
+                guard status.deliveryStatus != .suspended else {
+                    self.log.info("Canceling temp basal because status return indicates pod is suspended!")
+                    completion(.failure(PumpManagerError.deviceState(PodCommsError.podSuspended)))
+                    return
+                }
+            } else {
+                self.log.info("Skipped Cancel TB command before enacting temp basal")
             }
 
             defer {
@@ -1605,8 +1653,7 @@ extension OmnipodPumpManager: PumpManager {
                 })
             }
 
-            if duration < .ulpOfOne {
-                // 0 duration temp basals are used to cancel any existing temp basal
+            if resumingNormalBasal {
                 self.setState({ (state) in
                     state.tempBasalEngageState = .disengaging
                 })
@@ -1631,6 +1678,7 @@ extension OmnipodPumpManager: PumpManager {
                 case .success:
                     completion(.success(dose))
                 case .uncertainFailure(let error):
+                    // TODO: Return PumpManagerError.uncertainDelivery and implement recovery
                     self.log.error("Temp basal uncertain error: %@", String(describing: error))
                     completion(.success(dose))
                 case .certainFailure(let error):

+ 15 - 15
Dependencies/rileylink_ios/OmniKit/PumpManager/OmnipodPumpManagerState.swift

@@ -52,6 +52,10 @@ public struct OmnipodPumpManagerState: RawRepresentable, Equatable {
     
     internal var insulinType: InsulinType
 
+    public var rileyLinkBatteryAlertLevel: Int?
+
+    public var lastRileyLinkBatteryAlertDate: Date = .distantPast
+
     // MARK: -
 
     public init(podState: PodState?, timeZone: TimeZone, basalSchedule: BasalSchedule, rileyLinkConnectionManagerState: RileyLinkConnectionManagerState?, insulinType: InsulinType) {
@@ -146,6 +150,9 @@ public struct OmnipodPumpManagerState: RawRepresentable, Equatable {
         if let pairingAttemptAddress = rawValue["pairingAttemptAddress"] as? UInt32 {
             self.pairingAttemptAddress = pairingAttemptAddress
         }
+
+        rileyLinkBatteryAlertLevel = rawValue["rileyLinkBatteryAlertLevel"] as? Int
+        lastRileyLinkBatteryAlertDate = rawValue["lastRileyLinkBatteryAlertDate"] as? Date ?? Date.distantPast
     }
     
     public var rawValue: RawValue {
@@ -159,22 +166,13 @@ public struct OmnipodPumpManagerState: RawRepresentable, Equatable {
             "insulinType": insulinType.rawValue,
         ]
         
-        if let podState = podState {
-            value["podState"] = podState.rawValue
-        }
+        value["podState"] = podState?.rawValue
+        value["expirationReminderDate"] = expirationReminderDate
+        value["rileyLinkConnectionManagerState"] = rileyLinkConnectionManagerState?.rawValue
+        value["pairingAttemptAddress"] = pairingAttemptAddress
+        value["rileyLinkBatteryAlertLevel"] = rileyLinkBatteryAlertLevel
+        value["lastRileyLinkBatteryAlertDate"] = lastRileyLinkBatteryAlertDate
 
-        if let expirationReminderDate = expirationReminderDate {
-            value["expirationReminderDate"] = expirationReminderDate
-        }
-        
-        if let rileyLinkConnectionManagerState = rileyLinkConnectionManagerState {
-            value["rileyLinkConnectionManagerState"] = rileyLinkConnectionManagerState.rawValue
-        }
-        
-        if let pairingAttemptAddress = pairingAttemptAddress {
-            value["pairingAttemptAddress"] = pairingAttemptAddress
-        }
-        
         return value
     }
 }
@@ -213,6 +211,8 @@ extension OmnipodPumpManagerState: CustomDebugStringConvertible {
             "* automaticBolusBeeps: \(String(describing: automaticBolusBeeps))",
             "* pairingAttemptAddress: \(String(describing: pairingAttemptAddress))",
             "* insulinType: \(String(describing: insulinType))",
+            "* rileyLinkBatteryAlertLevel: \(String(describing: rileyLinkBatteryAlertLevel))",
+            "* lastRileyLinkBatteryAlertDate \(String(describing: lastRileyLinkBatteryAlertDate))",
             String(reflecting: podState),
             String(reflecting: rileyLinkConnectionManagerState),
         ].joined(separator: "\n")

+ 57 - 12
Dependencies/rileylink_ios/OmniKit/PumpManager/PodComms.swift

@@ -11,8 +11,9 @@ import RileyLinkBLEKit
 import LoopKit
 import os.log
 
+fileprivate var diagnosePairingRssi = false
 
-protocol PodCommsDelegate: class {
+protocol PodCommsDelegate: AnyObject {
     func podComms(_ podComms: PodComms, didChange podState: PodState)
 }
 
@@ -69,9 +70,11 @@ class PodComms: CustomDebugStringConvertible {
     ///     - PodCommsError.emptyResponse
     ///     - PodCommsError.unexpectedResponse
     ///     - PodCommsError.podChange
+    ///     - PodCommsError.activationTimeExceeded
     ///     - PodCommsError.rssiTooLow
     ///     - PodCommsError.rssiTooHigh
-    ///     - PodCommsError.activationTimeExceeded
+    ///     - PodCommsError.diagnosticMessage
+    ///     - PodCommsError.podIncompatible
     ///     - MessageError.invalidCrc
     ///     - MessageError.invalidSequence
     ///     - MessageError.invalidAddress
@@ -140,12 +143,16 @@ class PodComms: CustomDebugStringConvertible {
                 throw PodCommsError.podChange
             }
 
-            // Checking RSSI
+            // Check the pod RSSI
             let maxRssiAllowed = 59         // maximum RSSI limit allowed
             let minRssiAllowed = 30         // minimum RSSI limit allowed
             if let rssi = config.rssi, let gain = config.gain {
-                let rssiStr = String(format: "Receiver Low Gain: %d.\nReceived Signal Strength Indicator: %d", gain, rssi)
-                log.default("%s", rssiStr)
+                let rssiStr = String(format: "RSSI: %u.\nReceiver Low Gain: %u", rssi, gain)
+                log.default("%@", rssiStr)
+                if diagnosePairingRssi {
+                    throw PodCommsError.diagnosticMessage(str: rssiStr)
+                }
+
                 rssiRetries -= 1
                 if rssi < minRssiAllowed {
                     log.default("RSSI value %d is less than minimum allowed value of %d, %d retries left", rssi, minRssiAllowed, rssiRetries)
@@ -185,9 +192,44 @@ class PodComms: CustomDebugStringConvertible {
                 throw PodCommsError.activationTimeExceeded
             }
 
-            if config.podProgressStatus == .pairingCompleted {
-                log.info("Version Response %{public}@ indicates pairing is complete, moving pod to configured state", String(describing: config))
-                self.podState?.setupProgress = .podConfigured
+            // It's unlikely that Insulet will release an updated Eros pod using any different fundemental values,
+            // so just verify that the fundemental pod constants returned match the expected constant values in the Pod struct.
+            // To actually be able to handle different fundemental values in Loop things would need to be reworked to save
+            // these values in some persistent PodState and then make sure that everything properly works using these values.
+            var errorStrings: [String] = []
+            if let pulseSize = config.pulseSize, pulseSize != Pod.pulseSize  {
+                errorStrings.append(String(format: "Pod reported pulse size of %.3fU different than expected %.3fU", pulseSize, Pod.pulseSize))
+            }
+            if let secondsPerBolusPulse = config.secondsPerBolusPulse, secondsPerBolusPulse != Pod.secondsPerBolusPulse  {
+                errorStrings.append(String(format: "Pod reported seconds per pulse rate of %.1f different than expected %.1f", secondsPerBolusPulse, Pod.secondsPerBolusPulse))
+            }
+            if let secondsPerPrimePulse = config.secondsPerPrimePulse, secondsPerPrimePulse != Pod.secondsPerPrimePulse  {
+                errorStrings.append(String(format: "Pod reported seconds per prime pulse rate of %.1f different than expected %.1f", secondsPerPrimePulse, Pod.secondsPerPrimePulse))
+            }
+            if let primeUnits = config.primeUnits, primeUnits != Pod.primeUnits {
+                errorStrings.append(String(format: "Pod reported prime bolus of %.2fU different than expected %.2fU", primeUnits, Pod.primeUnits))
+            }
+            if let cannulaInsertionUnits = config.cannulaInsertionUnits, Pod.cannulaInsertionUnits != cannulaInsertionUnits {
+                errorStrings.append(String(format: "Pod reported cannula insertion bolus of %.2fU different than expected %.2fU", cannulaInsertionUnits, Pod.cannulaInsertionUnits))
+            }
+            if let serviceDuration = config.serviceDuration {
+                if serviceDuration < Pod.serviceDuration {
+                    errorStrings.append(String(format: "Pod reported service duration of %.0f hours shorter than expected %.0f", serviceDuration.hours, Pod.serviceDuration.hours))
+                } else if serviceDuration > Pod.serviceDuration {
+                    log.info("Pod reported service duration of %.0f hours limited to expected %.0f", serviceDuration.hours, Pod.serviceDuration.hours)
+                }
+            }
+
+            let errMess = errorStrings.joined(separator: ".\n")
+            if errMess.isEmpty == false {
+                log.error("%@", errMess)
+                self.podState?.setupProgress = .podIncompatible
+                throw PodCommsError.podIncompatible(str: errMess)
+            }
+
+            if config.podProgressStatus == .pairingCompleted && self.podState?.setupProgress.isPaired == false {
+                log.info("Version Response %{public}@ indicates pairing is now complete", String(describing: config))
+                self.podState?.setupProgress = .podPaired
             }
 
             return config
@@ -234,8 +276,11 @@ class PodComms: CustomDebugStringConvertible {
             versionResponse = try sendPairMessage(address: podState.address, transport: transport, message: message, insulinType: insulinType)
         } catch let error {
             if case PodCommsError.podAckedInsteadOfReturningResponse = error {
-                log.default("SetupPod acked instead of returning response. Moving pod to configured state.")
-                self.podState?.setupProgress = .podConfigured
+                log.default("SetupPod acked instead of returning response.")
+                if self.podState?.setupProgress.isPaired == false {
+                    log.default("Moving pod to paired state.")
+                    self.podState?.setupProgress = .podPaired
+                }
                 return
             }
             log.error("SetupPod returns error %{public}@", String(describing: error))
@@ -275,11 +320,11 @@ class PodComms: CustomDebugStringConvertible {
                         return
                     }
 
-                    if self.podState!.setupProgress != .podConfigured {
+                    if self.podState!.setupProgress.isPaired == false {
                         try self.setupPod(podState: self.podState!, timeZone: timeZone, commandSession: commandSession, insulinType: insulinType)
                     }
 
-                    guard self.podState!.setupProgress == .podConfigured else {
+                    guard self.podState!.setupProgress.isPaired else {
                         self.log.error("Unexpected podStatus setupProgress value of %{public}@", String(describing: self.podState!.setupProgress))
                         throw PodCommsError.invalidData
                     }

+ 112 - 19
Dependencies/rileylink_ios/OmniKit/PumpManager/PodCommsSession.swift

@@ -33,6 +33,8 @@ public enum PodCommsError: Error {
     case activationTimeExceeded
     case rssiTooLow
     case rssiTooHigh
+    case diagnosticMessage(str: String)
+    case podIncompatible(str: String)
 }
 
 extension PodCommsError: LocalizedError {
@@ -81,6 +83,10 @@ extension PodCommsError: LocalizedError {
             return LocalizedString("Poor signal strength", comment: "Format string for poor pod signal strength")
         case .rssiTooHigh: // only occurs when RileyLink is too close to the pod for reliable pairing
             return LocalizedString("Signal strength too high", comment: "Format string for pod signal strength too high")
+        case .diagnosticMessage(let str):
+            return str
+        case .podIncompatible(let str):
+            return str
         }
     }
     
@@ -132,11 +138,24 @@ extension PodCommsError: LocalizedError {
             return LocalizedString("Please reposition the RileyLink relative to the pod", comment: "Recovery suggestion when pairing signal strength is too low")
         case .rssiTooHigh:
             return LocalizedString("Please reposition the RileyLink further from the pod", comment: "Recovery suggestion when pairing signal strength is too high")
+        case .diagnosticMessage:
+            return nil
+        case .podIncompatible:
+            return nil
+        }
+    }
+
+    public var isFaulted: Bool {
+        switch self {
+        case .podFault, .activationTimeExceeded, .podIncompatible:
+            return true
+        default:
+            return false
         }
     }
 }
 
-public protocol PodCommsSessionDelegate: class {
+public protocol PodCommsSessionDelegate: AnyObject {
     func podCommsSession(_ podCommsSession: PodCommsSession, didChange state: PodState)
 }
 
@@ -279,19 +298,19 @@ public class PodCommsSession {
 
     // Returns time at which prime is expected to finish.
     public func prime() throws -> TimeInterval {
-        //4c00 00c8 0102
-
-        let primeDuration = TimeInterval(seconds: 55)   // a bit more than (Pod.primeUnits / Pod.primeDeliveryRate)
+        let primeDuration: TimeInterval = .seconds(Pod.primeUnits / Pod.primeDeliveryRate) + 3 // as per PDM
         
-        // Skip following alerts if we've already done them before
-        if podState.setupProgress != .startingPrime {
-            
-            // The following will set Tab5[$16] to 0 during pairing, which disables $6x faults.
+        // If priming has never been attempted on this pod, handle the pre-prime setup tasks.
+        // A FaultConfig can only be done before the prime bolus or the pod will generate an 049 fault.
+        if podState.setupProgress.primingNeverAttempted {
+            // This FaultConfig command will set Tab5[$16] to 0 during pairing, which disables $6x faults
             let _: StatusResponse = try send([FaultConfigCommand(nonce: podState.currentNonce, tab5Sub16: 0, tab5Sub17: 0)])
+
+            // Set up the finish pod setup reminder alert which beeps every 5 minutes for 1 hour
             let finishSetupReminder = PodAlert.finishSetupReminder
             try configureAlerts([finishSetupReminder])
         } else {
-            // We started prime, but didn't get confirmation somehow, so check status
+            // Not the first time through, check to see if prime bolus was successfully started
             let status: StatusResponse = try send([GetStatusCommand()])
             podState.updateFromStatusResponse(status)
             if status.podProgressStatus == .priming || status.podProgressStatus == .primingCompleted {
@@ -300,7 +319,7 @@ public class PodCommsSession {
             }
         }
 
-        // Mark 2.6U delivery with 1 second between pulses for prime
+        // Mark Pod.primeUnits (2.6U) bolus delivery with Pod.primeDeliveryRate (1) between pulses for prime
         
         let primeFinishTime = Date() + primeDuration
         podState.primeFinishTime = primeFinishTime
@@ -323,6 +342,7 @@ public class PodCommsSession {
             podState.updateFromStatusResponse(status)
             if status.podProgressStatus == .basalInitialized {
                 podState.setupProgress = .initialBasalScheduleSet
+                podState.finalizedDoses.append(UnfinalizedDose(resumeStartTime: Date(), scheduledCertainty: .certain, insulinType: podState.insulinType))
                 return
             }
         }
@@ -335,10 +355,10 @@ public class PodCommsSession {
     }
 
     @discardableResult
-    private func configureAlerts(_ alerts: [PodAlert]) throws -> StatusResponse {
+    private func configureAlerts(_ alerts: [PodAlert], confirmationBeepType: BeepConfigType? = nil) throws -> StatusResponse {
         let configurations = alerts.map { $0.configuration }
         let configureAlerts = ConfigureAlertsCommand(nonce: podState.currentNonce, configurations: configurations)
-        let status: StatusResponse = try send([configureAlerts])
+        let status: StatusResponse = try send([configureAlerts], confirmationBeepType: confirmationBeepType)
         for alert in alerts {
             podState.registerConfiguredAlert(slot: alert.configuration.slot, alert: alert)
         }
@@ -372,7 +392,8 @@ public class PodCommsSession {
     }
 
     public func insertCannula() throws -> TimeInterval {
-        let insertionWait: TimeInterval = .seconds(Pod.cannulaInsertionUnits / Pod.primeDeliveryRate)
+        let cannulaInsertionUnits = Pod.cannulaInsertionUnits + Pod.cannulaInsertionUnitsExtra
+        let insertionWait: TimeInterval = .seconds(cannulaInsertionUnits / Pod.primeDeliveryRate)
 
         guard let activatedAt = podState.activatedAt else {
             throw PodCommsError.noPodPaired
@@ -402,14 +423,14 @@ public class PodCommsSession {
             try configureAlerts([expirationAdvisoryAlarm, shutdownImminentAlarm])
         }
         
-        // Mark 0.5U delivery with 1 second between pulses for cannula insertion
+        // Mark cannulaInsertionUnits (0.5U) bolus delivery with Pod.secondsPerPrimePulse (1) between pulses for cannula insertion
 
         let timeBetweenPulses = TimeInterval(seconds: Pod.secondsPerPrimePulse)
-        let bolusSchedule = SetInsulinScheduleCommand.DeliverySchedule.bolus(units: Pod.cannulaInsertionUnits, timeBetweenPulses: timeBetweenPulses)
+        let bolusSchedule = SetInsulinScheduleCommand.DeliverySchedule.bolus(units: cannulaInsertionUnits, timeBetweenPulses: timeBetweenPulses)
         let bolusScheduleCommand = SetInsulinScheduleCommand(nonce: podState.currentNonce, deliverySchedule: bolusSchedule)
         
         podState.setupProgress = .startingInsertCannula
-        let bolusExtraCommand = BolusExtraCommand(units: Pod.cannulaInsertionUnits, timeBetweenPulses: timeBetweenPulses)
+        let bolusExtraCommand = BolusExtraCommand(units: cannulaInsertionUnits, timeBetweenPulses: timeBetweenPulses)
         let status2: StatusResponse = try send([bolusScheduleCommand, bolusExtraCommand])
         podState.updateFromStatusResponse(status2)
         
@@ -544,6 +565,77 @@ public class PodCommsSession {
         return canceledDose
     }
     
+    // Suspends insulin delivery and sets appropriate podSuspendedReminder & suspendTimeExpired alerts.
+    // A nil suspendReminder is an untimed suspend with no suspend reminders.
+    // A suspendReminder of 0 is an untimed suspend which only uses podSuspendedReminder alert beeps.
+    // A suspendReminder of 1-5 minutes will only use suspendTimeExpired alert beeps.
+    // A suspendReminder of > 5 min will have periodic podSuspendedReminder beeps followed by suspendTimeExpired alerts.
+    public func suspendDelivery(suspendReminder: TimeInterval? = nil, confirmationBeepType: BeepConfigType? = nil) -> CancelDeliveryResult {
+        do {
+            var alertConfigurations: [AlertConfiguration] = []
+            var podSuspendedReminderAlert: PodAlert? = nil
+            var suspendTimeExpiredAlert: PodAlert? = nil
+            let suspendTime: TimeInterval = suspendReminder != nil ? suspendReminder! : 0
+
+            let cancelDeliveryCommand = CancelDeliveryCommand(nonce: podState.currentNonce, deliveryType: .all, beepType: .noBeep)
+            var commandsToSend: [MessageBlock] = [cancelDeliveryCommand]
+
+            // podSuspendedReminder provides a periodic pod suspended reminder beep until the specified suspend time.
+            if suspendReminder != nil && (suspendTime == 0 || suspendTime > .minutes(5)) {
+                // using reminder beeps for an untimed or long enough suspend time requiring pod suspended reminders
+                podSuspendedReminderAlert = PodAlert.podSuspendedReminder(active: true, suspendTime: suspendTime)
+                alertConfigurations += [podSuspendedReminderAlert!.configuration]
+            }
+
+            // suspendTimeExpired provides suspend time expired alert beeping after the expected suspend time has passed.
+            if suspendTime > 0 {
+                // a timed suspend using a suspend time expired alert
+                suspendTimeExpiredAlert = PodAlert.suspendTimeExpired(suspendTime: suspendTime)
+                alertConfigurations += [suspendTimeExpiredAlert!.configuration]
+            }
+
+            // append a ConfigureAlert command if we have any reminder alerts for this suspend
+            if alertConfigurations.count != 0 {
+                let configureAlerts = ConfigureAlertsCommand(nonce: podState.currentNonce, configurations: alertConfigurations)
+                commandsToSend += [configureAlerts]
+            }
+
+            let status: StatusResponse = try send(commandsToSend, confirmationBeepType: confirmationBeepType)
+            let canceledDose = handleCancelDosing(deliveryType: .all, bolusNotDelivered: status.bolusNotDelivered)
+            podState.updateFromStatusResponse(status)
+
+            if let alert = podSuspendedReminderAlert {
+                podState.registerConfiguredAlert(slot: alert.configuration.slot, alert: alert)
+            }
+            if let alert = suspendTimeExpiredAlert {
+                podState.registerConfiguredAlert(slot: alert.configuration.slot, alert: alert)
+            }
+
+            return CancelDeliveryResult.success(statusResponse: status, canceledDose: canceledDose)
+        } catch PodCommsError.nonceResyncFailed {
+            return CancelDeliveryResult.certainFailure(error: PodCommsError.nonceResyncFailed)
+        } catch PodCommsError.rejectedMessage(let errorCode) {
+            return CancelDeliveryResult.certainFailure(error: PodCommsError.rejectedMessage(errorCode: errorCode))
+        } catch let error {
+            podState.unfinalizedSuspend = UnfinalizedDose(suspendStartTime: Date(), scheduledCertainty: .uncertain)
+            return CancelDeliveryResult.uncertainFailure(error: error as? PodCommsError ?? PodCommsError.commsError(error: error))
+        }
+    }
+
+    // Cancels any suspend related alerts, should be called when resuming after using suspendDelivery()
+    @discardableResult
+    public func cancelSuspendAlerts() throws -> StatusResponse {
+        do {
+            let podSuspendedReminder = PodAlert.podSuspendedReminder(active: false, suspendTime: 0)
+            let suspendTimeExpired = PodAlert.suspendTimeExpired(suspendTime: 0) // A suspendTime of 0 deactivates this alert
+
+            let status = try configureAlerts([podSuspendedReminder, suspendTimeExpired])
+            return status
+        } catch let error {
+            throw error
+        }
+    }
+
     // Cancel beeping can be done implemented using beepType (for a single delivery type) or a separate confirmation beep message block (for cancel all).
     // N.B., Using the built-in cancel delivery command beepType method when cancelling all insulin delivery will emit 3 different sets of cancel beeps!!!
     public func cancelDelivery(deliveryType: CancelDeliveryCommand.DeliveryType, beepType: BeepType = .noBeep, confirmationBeepType: BeepConfigType? = nil) -> CancelDeliveryResult {
@@ -672,6 +764,7 @@ public class PodCommsSession {
         return podInfoResponse
     }
 
+    // Can be called a second time to deactivate a given pod
     public func deactivatePod() throws {
 
         // Don't try to cancel if the pod hasn't completed its setup as it will either receive no response
@@ -688,6 +781,7 @@ public class PodCommsSession {
             }
         }
 
+        // if faulted read the most recent pulse log entries
         if podState.fault != nil {
             // All the dosing cleanup from the fault should have already been
             // handled in handlePodFault() when podState.fault was initialized.
@@ -699,13 +793,12 @@ public class PodCommsSession {
             }
         }
 
-        let deactivatePod = DeactivatePodCommand(nonce: podState.currentNonce)
-
         do {
+            let deactivatePod = DeactivatePodCommand(nonce: podState.currentNonce)
             let _: StatusResponse = try send([deactivatePod])
         } catch let error as PodCommsError {
             switch error {
-            case .podFault, .unexpectedResponse:
+            case .podFault, .activationTimeExceeded, .unexpectedResponse:
                 break
             default:
                 throw error

+ 15 - 4
Dependencies/rileylink_ios/OmniKit/PumpManager/PodState.swift

@@ -11,7 +11,7 @@ import LoopKit
 
 public enum SetupProgress: Int {
     case addressAssigned = 0
-    case podConfigured
+    case podPaired
     case startingPrime
     case priming
     case settingInitialBasalSchedule
@@ -20,6 +20,15 @@ public enum SetupProgress: Int {
     case cannulaInserting
     case completed
     case activationTimeout
+    case podIncompatible
+
+    public var isPaired: Bool {
+        return self.rawValue >= SetupProgress.podPaired.rawValue
+    }
+
+    public var primingNeverAttempted: Bool {
+        return self.rawValue < SetupProgress.startingPrime.rawValue
+    }
     
     public var primingNeeded: Bool {
         return self.rawValue < SetupProgress.priming.rawValue
@@ -120,7 +129,7 @@ public struct PodState: RawRepresentable, Equatable, CustomDebugStringConvertibl
         self.lastCommsOK = false
     }
     
-    public var unfinishedPairing: Bool {
+    public var unfinishedSetup: Bool {
         return setupProgress != .completed
     }
     
@@ -141,7 +150,7 @@ public struct PodState: RawRepresentable, Equatable, CustomDebugStringConvertibl
     }
 
     public var isFaulted: Bool {
-        return fault != nil || setupProgress == .activationTimeout
+        return fault != nil || setupProgress == .activationTimeout || setupProgress == .podIncompatible
     }
 
     public mutating func advanceToNextNonce() {
@@ -400,6 +409,8 @@ public struct PodState: RawRepresentable, Equatable, CustomDebugStringConvertibl
                 .slot2: .shutdownImminentAlarm(0),
                 .slot3: .expirationAlert(0),
                 .slot4: .lowReservoirAlarm(0),
+                .slot5: .podSuspendedReminder(active: false, suspendTime: 0),
+                .slot6: .suspendTimeExpired(suspendTime: 0),
                 .slot7: .expirationAdvisoryAlarm(alarmTime: 0, duration: 0)
             ]
         }
@@ -409,7 +420,7 @@ public struct PodState: RawRepresentable, Equatable, CustomDebugStringConvertibl
         if let rawInsulinType = rawValue["insulinType"] as? InsulinType.RawValue, let insulinType = InsulinType(rawValue: rawInsulinType) {
             self.insulinType = insulinType
         } else {
-            insulinType = .humalog
+            insulinType = .novolog
         }
 
         self.deliveryStatusVerified = false

+ 40 - 4
Dependencies/rileylink_ios/OmniKitTests/MessageTests.swift

@@ -39,7 +39,7 @@ class MessageTests: XCTestCase {
             XCTAssertEqual(.aboveFiftyUnits, statusResponse.podProgressStatus)
             XCTAssertEqual(6.3, statusResponse.insulin, accuracy: 0.01)
             XCTAssertEqual(0, statusResponse.bolusNotDelivered)
-            XCTAssertEqual(3, statusResponse.podMessageCounter)
+            XCTAssertEqual(3, statusResponse.lastProgrammingMessageSeqNum)
             XCTAssert(statusResponse.alerts.isEmpty)
 
             XCTAssertEqual("1f00ee84300a1d18003f1800004297ff8128", msg.encoded().hexadecimalString)
@@ -78,9 +78,21 @@ class MessageTests: XCTestCase {
         do {
             let config = try VersionResponse(encodedData: Data(hexadecimalString: "011502070002070002020000a64000097c279c1f08ced2")!)
             XCTAssertEqual(23, config.data.count)
+            XCTAssertEqual("2.7.0", String(describing: config.piVersion))
+            XCTAssertEqual("2.7.0", String(describing: config.pmVersion))
             XCTAssertEqual(0x1f08ced2, config.address)
             XCTAssertEqual(42560, config.lot)
             XCTAssertEqual(621607, config.tid)
+            XCTAssertEqual(2, config.productID)
+            XCTAssertEqual(.reminderInitialized, config.podProgressStatus)
+            XCTAssertEqual(2, config.gain)
+            XCTAssertEqual(0x1c, config.rssi)
+            XCTAssertNil(config.pulseSize)
+            XCTAssertNil(config.secondsPerBolusPulse)
+            XCTAssertNil(config.secondsPerPrimePulse)
+            XCTAssertNil(config.primeUnits)
+            XCTAssertNil(config.cannulaInsertionUnits)
+            XCTAssertNil(config.serviceDuration)
         } catch (let error) {
             XCTFail("message decoding threw error: \(error)")
         }
@@ -91,11 +103,21 @@ class MessageTests: XCTestCase {
             let message = try Message(encodedData: Data(hexadecimalString: "ffffffff041d011b13881008340a5002070002070002030000a62b000447941f00ee878352")!)
             let config = message.messageBlocks[0] as! VersionResponse
             XCTAssertEqual(29, config.data.count)
-            XCTAssertEqual(0x1f00ee87, config.address)
-            XCTAssertEqual(42539, config.lot)
-            XCTAssertEqual(280468, config.tid)
             XCTAssertEqual("2.7.0", String(describing: config.piVersion))
             XCTAssertEqual("2.7.0", String(describing: config.pmVersion))
+            XCTAssertEqual(42539, config.lot)
+            XCTAssertEqual(280468, config.tid)
+            XCTAssertEqual(0x1f00ee87, config.address)
+            XCTAssertEqual(2, config.productID)
+            XCTAssertEqual(.pairingCompleted, config.podProgressStatus)
+            XCTAssertNil(config.rssi)
+            XCTAssertNil(config.gain)
+            XCTAssertEqual(Pod.pulseSize, config.pulseSize)
+            XCTAssertEqual(Pod.secondsPerBolusPulse, config.secondsPerBolusPulse)
+            XCTAssertEqual(Pod.secondsPerPrimePulse, config.secondsPerPrimePulse)
+            XCTAssertEqual(Pod.primeUnits, config.primeUnits)
+            XCTAssertEqual(Pod.cannulaInsertionUnits, config.cannulaInsertionUnits)
+            XCTAssertEqual(Pod.serviceDuration, config.serviceDuration)
         } catch (let error) {
             XCTFail("message decoding threw error: \(error)")
         }
@@ -105,7 +127,21 @@ class MessageTests: XCTestCase {
         do {
             let message = try Message(encodedData: Data(hexadecimalString: "ffffffff04170115020700020700020e0000a5ad00053030971f08686301fd")!)
             let config = message.messageBlocks[0] as! VersionResponse
+            XCTAssertEqual("2.7.0", String(describing: config.piVersion))
+            XCTAssertEqual("2.7.0", String(describing: config.pmVersion))
+            XCTAssertEqual(0x0000a5ad, config.lot)
+            XCTAssertEqual(0x00053030, config.tid)
+            XCTAssertEqual(0x1f086863, config.address)
+            XCTAssertEqual(2, config.productID)
             XCTAssertEqual(.activationTimeExceeded, config.podProgressStatus)
+            XCTAssertEqual(2, config.gain)
+            XCTAssertEqual(0x17, config.rssi)
+            XCTAssertNil(config.pulseSize)
+            XCTAssertNil(config.secondsPerBolusPulse)
+            XCTAssertNil(config.secondsPerPrimePulse)
+            XCTAssertNil(config.primeUnits)
+            XCTAssertNil(config.cannulaInsertionUnits)
+            XCTAssertNil(config.serviceDuration)
         } catch (let error) {
             XCTFail("message decoding threw error: \(error)")
         }

+ 16 - 16
Dependencies/rileylink_ios/OmniKitTests/PodInfoTests.swift

@@ -21,7 +21,7 @@ class PodInfoTests: XCTestCase {
             XCTAssertEqual(faultEvent.faultAccessingTables, false)
             XCTAssertEqual(faultEvent.podProgressStatus, .faultEventOccurred)
             XCTAssertEqual(faultEvent.errorEventInfo?.insulinStateTableCorruption, false)
-            XCTAssertEqual(faultEvent.errorEventInfo?.internalVariable, 1)
+            XCTAssertEqual(faultEvent.errorEventInfo?.occlusionType, 1)
             XCTAssertEqual(faultEvent.errorEventInfo?.immediateBolusInProgress, false)
             XCTAssertEqual(faultEvent.errorEventInfo?.podProgressStatus, .aboveFiftyUnits)
         } catch (let error) {
@@ -127,7 +127,7 @@ class PodInfoTests: XCTestCase {
             XCTAssertEqual(.aboveFiftyUnits, decoded.podProgressStatus)
             XCTAssertEqual(.scheduledBasal, decoded.deliveryStatus)
             XCTAssertEqual(0000, decoded.bolusNotDelivered)
-            XCTAssertEqual(0x0a, decoded.podMessageCounter)
+            XCTAssertEqual(0x0a, decoded.lastProgrammingMessageSeqNum)
             XCTAssertEqual(.noFaults, decoded.faultEventCode.faultType)
             XCTAssertEqual(TimeInterval(minutes: 0x0000), decoded.faultEventTimeSinceActivation)
             XCTAssertNil(decoded.reservoirLevel)
@@ -156,7 +156,7 @@ class PodInfoTests: XCTestCase {
             XCTAssertEqual(.inactive, decoded.podProgressStatus)
             XCTAssertEqual(.suspended, decoded.deliveryStatus)
             XCTAssertEqual(0000, decoded.bolusNotDelivered)
-            XCTAssertEqual(9, decoded.podMessageCounter)
+            XCTAssertEqual(9, decoded.lastProgrammingMessageSeqNum)
             XCTAssertEqual(.primeOpenCountTooLow, decoded.faultEventCode.faultType)
             XCTAssertEqual(TimeInterval(minutes: 0x0001), decoded.faultEventTimeSinceActivation)
             XCTAssertNil(decoded.reservoirLevel)
@@ -165,7 +165,7 @@ class PodInfoTests: XCTestCase {
             XCTAssertEqual(00, decoded.unacknowledgedAlerts.rawValue)
             XCTAssertEqual(false, decoded.faultAccessingTables)
             XCTAssertEqual(false, decoded.errorEventInfo?.insulinStateTableCorruption)
-            XCTAssertEqual(0, decoded.errorEventInfo?.internalVariable)
+            XCTAssertEqual(0, decoded.errorEventInfo?.occlusionType)
             XCTAssertEqual(false, decoded.errorEventInfo?.immediateBolusInProgress)
             XCTAssertEqual(.primingCompleted, decoded.errorEventInfo?.podProgressStatus)
             XCTAssertEqual(0b10, decoded.receiverLowGain)
@@ -188,7 +188,7 @@ class PodInfoTests: XCTestCase {
             XCTAssertEqual(.faultEventOccurred, decoded.podProgressStatus)
             XCTAssertEqual(.suspended, decoded.deliveryStatus)
             XCTAssertEqual(0, decoded.bolusNotDelivered, accuracy: 0.01)
-            XCTAssertEqual(6, decoded.podMessageCounter)
+            XCTAssertEqual(6, decoded.lastProgrammingMessageSeqNum)
             XCTAssertEqual(.command1AParseUnexpectedFailed, decoded.faultEventCode.faultType)
             XCTAssertEqual(TimeInterval(minutes: 0x0000), decoded.faultEventTimeSinceActivation)
             XCTAssertNil(decoded.reservoirLevel)
@@ -196,7 +196,7 @@ class PodInfoTests: XCTestCase {
             XCTAssertEqual(0, decoded.unacknowledgedAlerts.rawValue)
             XCTAssertEqual(false, decoded.faultAccessingTables)
             XCTAssertEqual(false, decoded.errorEventInfo?.insulinStateTableCorruption)
-            XCTAssertEqual(0, decoded.errorEventInfo?.internalVariable)
+            XCTAssertEqual(0, decoded.errorEventInfo?.occlusionType)
             XCTAssertEqual(PodProgressStatus.pairingCompleted, decoded.errorEventInfo?.podProgressStatus)
             XCTAssertEqual(0b10, decoded.receiverLowGain)
             XCTAssertEqual(0x22, decoded.radioRSSI)
@@ -218,7 +218,7 @@ class PodInfoTests: XCTestCase {
             XCTAssertEqual(.faultEventOccurred, decoded.podProgressStatus)
             XCTAssertEqual(.suspended, decoded.deliveryStatus)
             XCTAssertEqual(0, decoded.bolusNotDelivered)
-            XCTAssertEqual(4, decoded.podMessageCounter)
+            XCTAssertEqual(4, decoded.lastProgrammingMessageSeqNum)
             XCTAssertEqual(101.7, decoded.totalInsulinDelivered, accuracy: 0.01)
             XCTAssertEqual(.basalOverInfusionPulse, decoded.faultEventCode.faultType)
             XCTAssertEqual(0, decoded.unacknowledgedAlerts.rawValue)
@@ -228,7 +228,7 @@ class PodInfoTests: XCTestCase {
             XCTAssertEqual(TimeInterval(minutes: 0x0a02), decoded.timeActive)
             XCTAssertEqual(false, decoded.faultAccessingTables)
             XCTAssertEqual(false, decoded.errorEventInfo?.insulinStateTableCorruption)
-            XCTAssertEqual(0, decoded.errorEventInfo?.internalVariable)
+            XCTAssertEqual(0, decoded.errorEventInfo?.occlusionType)
             XCTAssertEqual(false, decoded.errorEventInfo?.immediateBolusInProgress)
             XCTAssertEqual(.aboveFiftyUnits, decoded.errorEventInfo?.podProgressStatus)
             XCTAssertEqual(0b00, decoded.receiverLowGain)
@@ -250,7 +250,7 @@ class PodInfoTests: XCTestCase {
             XCTAssertEqual(.faultEventOccurred, decoded.podProgressStatus)
             XCTAssertEqual(.suspended, decoded.deliveryStatus)
             XCTAssertEqual(0, decoded.bolusNotDelivered)
-            XCTAssertEqual(4, decoded.podMessageCounter)
+            XCTAssertEqual(4, decoded.lastProgrammingMessageSeqNum)
             XCTAssertEqual(101.35, decoded.totalInsulinDelivered, accuracy: 0.01)
             XCTAssertEqual(.occlusionCheckAboveThreshold, decoded.faultEventCode.faultType)
             XCTAssertEqual(0, decoded.unacknowledgedAlerts.rawValue)
@@ -260,7 +260,7 @@ class PodInfoTests: XCTestCase {
             XCTAssertEqual(TimeInterval(minutes: 0x0e14), decoded.timeActive)
             XCTAssertEqual(false, decoded.faultAccessingTables)
             XCTAssertEqual(false, decoded.errorEventInfo?.insulinStateTableCorruption)
-            XCTAssertEqual(1, decoded.errorEventInfo?.internalVariable)
+            XCTAssertEqual(1, decoded.errorEventInfo?.occlusionType)
             XCTAssertEqual(false, decoded.errorEventInfo?.immediateBolusInProgress)
             XCTAssertEqual(.aboveFiftyUnits, decoded.errorEventInfo?.podProgressStatus)
             XCTAssertEqual(0b00, decoded.receiverLowGain)
@@ -282,7 +282,7 @@ class PodInfoTests: XCTestCase {
             XCTAssertEqual(.inactive, decoded.podProgressStatus)
             XCTAssertEqual(.suspended, decoded.deliveryStatus)
             XCTAssertEqual(0.05, decoded.bolusNotDelivered)
-            XCTAssertEqual(2, decoded.podMessageCounter)
+            XCTAssertEqual(2, decoded.lastProgrammingMessageSeqNum)
             XCTAssertEqual(11.8, decoded.totalInsulinDelivered, accuracy: 0.01)
             XCTAssertEqual(.occlusionCheckAboveThreshold, decoded.faultEventCode.faultType)
             XCTAssertEqual(0, decoded.unacknowledgedAlerts.rawValue)
@@ -292,7 +292,7 @@ class PodInfoTests: XCTestCase {
             XCTAssertEqual(TimeInterval(minutes: 0x026b), decoded.timeActive)
             XCTAssertEqual(false, decoded.faultAccessingTables)
             XCTAssertEqual(false, decoded.errorEventInfo?.insulinStateTableCorruption)
-            XCTAssertEqual(1, decoded.errorEventInfo?.internalVariable)
+            XCTAssertEqual(1, decoded.errorEventInfo?.occlusionType)
             XCTAssertEqual(false, decoded.errorEventInfo?.immediateBolusInProgress)
             XCTAssertEqual(.aboveFiftyUnits, decoded.errorEventInfo?.podProgressStatus)
             XCTAssertEqual(0b10, decoded.receiverLowGain)
@@ -314,7 +314,7 @@ class PodInfoTests: XCTestCase {
             XCTAssertEqual(.faultEventOccurred, decoded.podProgressStatus)
             XCTAssertEqual(.suspended, decoded.deliveryStatus)
             XCTAssertEqual(0.00, decoded.bolusNotDelivered)
-            XCTAssertEqual(0, decoded.podMessageCounter)
+            XCTAssertEqual(0, decoded.lastProgrammingMessageSeqNum)
             XCTAssertEqual(0.00, decoded.totalInsulinDelivered, accuracy: 0.01)
             XCTAssertEqual(.resetDueToLVD, decoded.faultEventCode.faultType)
             XCTAssertNil(decoded.faultEventTimeSinceActivation)
@@ -323,7 +323,7 @@ class PodInfoTests: XCTestCase {
             XCTAssertEqual(0, decoded.unacknowledgedAlerts.rawValue)
             XCTAssertEqual(false, decoded.faultAccessingTables)
             XCTAssertEqual(true, decoded.errorEventInfo?.insulinStateTableCorruption)
-            XCTAssertEqual(0, decoded.errorEventInfo?.internalVariable)
+            XCTAssertEqual(0, decoded.errorEventInfo?.occlusionType)
             XCTAssertEqual(false, decoded.errorEventInfo?.immediateBolusInProgress)
             XCTAssertEqual(.insertingCannula, decoded.errorEventInfo?.podProgressStatus)
             XCTAssertEqual(0b10, decoded.receiverLowGain)
@@ -453,7 +453,7 @@ class PodInfoTests: XCTestCase {
             XCTAssertEqual(.faultEventOccurred, faultEvent.podProgressStatus)
             XCTAssertEqual(.suspended, faultEvent.deliveryStatus)
             XCTAssertEqual(0.00, faultEvent.bolusNotDelivered)
-            XCTAssertEqual(0, faultEvent.podMessageCounter)
+            XCTAssertEqual(0, faultEvent.lastProgrammingMessageSeqNum)
             XCTAssertEqual(0.00, faultEvent.totalInsulinDelivered, accuracy: 0.01)
             XCTAssertEqual(.resetDueToLVD, faultEvent.faultEventCode.faultType)
             XCTAssertNil(faultEvent.faultEventTimeSinceActivation)
@@ -462,7 +462,7 @@ class PodInfoTests: XCTestCase {
             XCTAssertEqual(0, faultEvent.unacknowledgedAlerts.rawValue)
             XCTAssertEqual(false, faultEvent.faultAccessingTables)
             XCTAssertEqual(true, faultEvent.errorEventInfo?.insulinStateTableCorruption)
-            XCTAssertEqual(0, faultEvent.errorEventInfo?.internalVariable)
+            XCTAssertEqual(0, faultEvent.errorEventInfo?.occlusionType)
             XCTAssertEqual(false, faultEvent.errorEventInfo?.immediateBolusInProgress)
             XCTAssertEqual(.insertingCannula, faultEvent.errorEventInfo?.podProgressStatus)
             XCTAssertEqual(0b10, faultEvent.receiverLowGain)

+ 1 - 1
Dependencies/rileylink_ios/OmniKitTests/StatusTests.swift

@@ -38,7 +38,7 @@ class StatusTests: XCTestCase {
             XCTAssertEqual(129.45, decoded.insulin, accuracy: 0.01)
             XCTAssertEqual(46.00, decoded.reservoirLevel)
             XCTAssertEqual(2.2, decoded.bolusNotDelivered)
-            XCTAssertEqual(9, decoded.podMessageCounter)
+            XCTAssertEqual(9, decoded.lastProgrammingMessageSeqNum)
             //XCTAssert(,decoded.alarms)
         } catch (let error) {
             XCTFail("message decoding threw error: \(error)")

+ 4 - 4
Dependencies/rileylink_ios/OmniKitUI/ViewControllers/CommandResponseViewController.swift

@@ -49,18 +49,18 @@ extension CommandResponseViewController {
         var result, str: String
 
         let formatter = DateComponentsFormatter()
-        formatter.unitsStyle = .full
-        formatter.allowedUnits = [.day, .hour, .minute]
+        formatter.allowedUnits = [.hour, .minute]
+        formatter.unitsStyle = .short
         if let timeStr = formatter.string(from: status.timeActive) {
             str = timeStr
         } else {
             str = String(format: LocalizedString("%1$@ minutes", comment: "The format string for minutes (1: number of minutes string)"), String(describing: Int(status.timeActive / 60)))
         }
-        result = String(format: LocalizedString("Pod Active: %1$@\n", comment: "The format string for Pod Active: (1: Pod active time string)"), str)
+        result = String(format: LocalizedString("Pod Active Time: %1$@\n", comment: "The format string for Pod Active Time: (1: formatted time)"), str)
 
         result += String(format: LocalizedString("Delivery Status: %1$@\n", comment: "The format string for Delivery Status: (1: delivery status string)"), String(describing: status.deliveryStatus))
 
-        result += String(format: LocalizedString("Pulse Count: %1$d\n", comment: "The format string for Pulse Count (1: pulse count)"), Int(status.totalInsulinDelivered / Pod.pulseSize))
+        result += String(format: LocalizedString("Pulse Count: %1$d\n", comment: "The format string for Pulse Count (1: pulse count)"), Int(round(status.totalInsulinDelivered / Pod.pulseSize)))
 
         result += String(format: LocalizedString("Reservoir Level: %1$@ U\n", comment: "The format string for Reservoir Level: (1: reservoir level string)"), status.reservoirLevel?.twoDecimals ?? "50+")
 

+ 14 - 6
Dependencies/rileylink_ios/OmniKitUI/ViewControllers/InsertCannulaSetupViewController.swift

@@ -11,7 +11,6 @@ import LoopKit
 import LoopKitUI
 import RileyLinkKit
 import OmniKit
-import SwiftUI
 
 class InsertCannulaSetupViewController: SetupTableViewController {
     
@@ -119,12 +118,21 @@ class InsertCannulaSetupViewController: SetupTableViewController {
             }
             loadingText = errorText
             
-            // If we have an error, update the continue state depending on whether the cannula insertion was started
-            if let podCommsError = lastError as? PodCommsError {
-                switch podCommsError {
-                case .podFault, .activationTimeExceeded:
-                    continueState = .fault
+            var podCommsError: PodCommsError? = nil
+            if let pumpManagerError = lastError as? PumpManagerError {
+                switch pumpManagerError {
+                case .communication(let error):
+                    podCommsError = error as? PodCommsError
                 default:
+                    break
+                }
+            }
+
+            // If we have an error, update the continue state depending on whether it's fatal or if the cannula insertion was started or not
+            if let podCommsError = podCommsError {
+                if podCommsError.isFaulted {
+                    continueState = .fault
+                } else {
                     continueState = initialOrNeedsCannulaInsertionCheck
                 }
             } else if lastError != nil {

+ 233 - 181
Dependencies/rileylink_ios/OmniKitUI/ViewControllers/OmnipodSettingsViewController.swift

@@ -27,6 +27,17 @@ public class ConfirmationBeepsTableViewCell: TextButtonTableViewCell {
     }
 }
 
+public class AutoBolusBeepsTableViewCell: TextButtonTableViewCell {
+
+    public func updateTextLabel(enabled: Bool) {
+        if enabled {
+            self.textLabel?.text = LocalizedString("Disable Automatic Bolus Beeps", comment: "Title text for button to disable automatic bolus beeps")
+        } else {
+            self.textLabel?.text = LocalizedString("Enable Automatic Bolus Beeps", comment: "Title text for button to enable automatic bolus beeps")
+        }
+    }
+}
+
 class OmnipodSettingsViewController: RileyLinkSettingsViewController {
 
     let pumpManager: OmnipodPumpManager
@@ -76,6 +87,13 @@ class OmnipodSettingsViewController: RileyLinkSettingsViewController {
         return cell
     }()
     
+    lazy var autoBolusBeepsTableViewCell: AutoBolusBeepsTableViewCell = {
+        let cell = AutoBolusBeepsTableViewCell(style: .default, reuseIdentifier: nil)
+        cell.updateTextLabel(enabled: pumpManager.automaticBolusBeeps)
+        cell.isEnabled = self.pumpManager.confirmationBeeps
+        return cell
+    }()
+
     var activityIndicator: UIActivityIndicatorView!
     var refreshButton: UIButton!
 
@@ -213,19 +231,19 @@ class OmnipodSettingsViewController: RileyLinkSettingsViewController {
     
     private enum Section: Int, CaseIterable {
         case status = 0
-        case podDetails
-        case diagnostics
         case configuration
         case rileyLinks
+        case diagnostics
+        case podDetails
         case deletePumpManager
     }
     
     private class func sectionList(_ podState: PodState?) -> [Section] {
         if let podState = podState {
-            if podState.unfinishedPairing {
+            if podState.unfinishedSetup {
                 return [.configuration, .rileyLinks]
             } else {
-                return [.status, .configuration, .rileyLinks, .podDetails, .diagnostics]
+                return [.status, .configuration, .rileyLinks, .diagnostics, .podDetails]
             }
         } else {
             return [.configuration, .rileyLinks, .deletePumpManager]
@@ -252,7 +270,7 @@ class OmnipodSettingsViewController: RileyLinkSettingsViewController {
     }
     
     private var configurationRows: [ConfigurationRow] {
-        if podState == nil || podState?.unfinishedPairing == true {
+        if podState == nil || podState?.unfinishedSetup == true {
             return [.replacePod]
         } else {
             return ConfigurationRow.allCases
@@ -262,6 +280,7 @@ class OmnipodSettingsViewController: RileyLinkSettingsViewController {
     private enum ConfigurationRow: Int, CaseIterable {
         case suspendResume = 0
         case enableDisableConfirmationBeeps
+        case enableDisableAutoBolusBeeps
         case reminder
         case timeZoneOffset
         case insulinType
@@ -269,8 +288,8 @@ class OmnipodSettingsViewController: RileyLinkSettingsViewController {
     }
     
     fileprivate enum StatusRow: Int, CaseIterable {
-        case activatedAt = 0
-        case expiresAt
+        case expiresAt = 0
+        case podActiveClock
         case bolus
         case basal
         case alarms
@@ -303,16 +322,16 @@ class OmnipodSettingsViewController: RileyLinkSettingsViewController {
     
     override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
         switch sections[section] {
-        case .podDetails:
-            return LocalizedString("Pod Details", comment: "The title of the device information section in settings")
-        case .diagnostics:
-            return LocalizedString("Diagnostics", comment: "The title of the configuration section in settings")
-        case .configuration:
-            return nil
         case .status:
-            return nil
+            return nil  // No title, appears below a pod picture
+        case .configuration:
+            return LocalizedString("Configuration", comment: "The title of the configuration section in settings")
         case .rileyLinks:
             return super.tableView(tableView, titleForHeaderInSection: section)
+        case .diagnostics:
+            return LocalizedString("Diagnostics", comment: "The title of the diagnostics section in settings")
+        case .podDetails:
+            return LocalizedString("Pod Details", comment: "The title of the pod details section in settings")
         case .deletePumpManager:
             return " "  // Use an empty string for more dramatic spacing
         }
@@ -329,66 +348,73 @@ class OmnipodSettingsViewController: RileyLinkSettingsViewController {
     
     override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
         switch sections[indexPath.section] {
-        case .podDetails:
+        case .status:
             let podState = self.podState!
-            switch PodDetailsRow(rawValue: indexPath.row)! {
-            case .podAddress:
-                let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
-                cell.textLabel?.text = LocalizedString("Assigned Address", comment: "The title text for the address assigned to the pod")
-                cell.detailTextLabel?.text = String(format:"%04X", podState.address)
-                return cell
-            case .podLot:
-                let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
-                cell.textLabel?.text = LocalizedString("Lot", comment: "The title of the cell showing the pod lot id")
-                cell.detailTextLabel?.text = String(format:"L%d", podState.lot)
-                return cell
-            case .podTid:
-                let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
-                cell.textLabel?.text = LocalizedString("TID", comment: "The title of the cell showing the pod TID")
-                cell.detailTextLabel?.text = String(format:"%07d", podState.tid)
-                return cell
-            case .piVersion:
-                let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
-                cell.textLabel?.text = LocalizedString("PI Version", comment: "The title of the cell showing the pod pi version")
-                cell.detailTextLabel?.text = podState.piVersion
-                return cell
-            case .pmVersion:
-                let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
-                cell.textLabel?.text = LocalizedString("PM Version", comment: "The title of the cell showing the pod pm version")
-                cell.detailTextLabel?.text = podState.pmVersion
+            let statusRow = StatusRow(rawValue: indexPath.row)!
+            if statusRow == .alarms {
+                let cell = tableView.dequeueReusableCell(withIdentifier: AlarmsTableViewCell.className, for: indexPath) as! AlarmsTableViewCell
+                cell.textLabel?.text = LocalizedString("Alarms", comment: "The title of the cell showing alarm status")
+                cell.alerts = podState.activeAlerts
                 return cell
             }
-        case .diagnostics:
-            
-            switch Diagnostics(rawValue: indexPath.row)! {
-            case .readPodStatus:
-                let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
-                cell.textLabel?.text = LocalizedString("Read Pod Status", comment: "The title of the command to read the pod status")
-                cell.accessoryType = .disclosureIndicator
-                return cell
-            case .playTestBeeps:
-                let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
-                cell.textLabel?.text = LocalizedString("Play Test Beeps", comment: "The title of the command to play test beeps")
-                cell.accessoryType = .disclosureIndicator
-                return cell
-            case .readPulseLog:
+            let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
+
+            switch statusRow {
+            case .expiresAt:
                 let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
-                cell.textLabel?.text = LocalizedString("Read Pulse Log", comment: "The title of the command to read the pulse log")
-                cell.accessoryType = .disclosureIndicator
+                if let expiresAt = podState.expiresAt {
+                    if expiresAt.timeIntervalSinceNow > 0 {
+                        cell.textLabel?.text = LocalizedString("Expires", comment: "The title of the cell showing the pod expiration")
+                    } else {
+                        cell.textLabel?.text = LocalizedString("Expired", comment: "The title of the cell showing the pod expiration after expiry")
+                    }
+                }
+                cell.setDetailDate(podState.expiresAt, formatter: dateFormatter)
                 return cell
-            case .testCommand:
+            case .podActiveClock:
                 let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
-                cell.textLabel?.text = LocalizedString("Test Command", comment: "The title of the command to run the test command")
-                cell.accessoryType = .disclosureIndicator
+                cell.textLabel?.text = LocalizedString("Pod Active Clock", comment: "The title of the cell showing the pod active clock")
+                cell.setDetailAge(podState.expiresAt?.addingTimeInterval(-Pod.nominalPodLife).timeIntervalSinceNow)
                 return cell
+            case .bolus:
+                cell.textLabel?.text = LocalizedString("Bolus Delivery", comment: "The title of the cell showing pod bolus status")
+
+                let deliveredUnits: Double?
+                if let dose = podState.unfinalizedBolus {
+                    deliveredUnits = pumpManager.roundToSupportedBolusVolume(units: dose.progress * dose.units)
+                } else {
+                    deliveredUnits = nil
+                }
+
+                cell.setDetailBolus(suspended: podState.isSuspended, dose: podState.unfinalizedBolus, deliveredUnits: deliveredUnits)
+                // TODO: This timer is in the wrong context; should be part of a custom bolus progress cell
+//              if bolusProgressTimer == nil {
+//                  bolusProgressTimer = Timer.scheduledTimer(withTimeInterval: .seconds(2), repeats: true) { [weak self] (_) in
+//                      self?.tableView.reloadRows(at: [indexPath], with: .none)
+//                  }
+//              }
+            case .basal:
+                cell.textLabel?.text = LocalizedString("Basal Delivery", comment: "The title of the cell showing pod basal status")
+                cell.setDetailBasal(suspended: podState.isSuspended, dose: podState.unfinalizedTempBasal)
+            case .reservoirLevel:
+                cell.textLabel?.text = LocalizedString("Reservoir", comment: "The title of the cell showing reservoir status")
+                cell.setReservoirDetail(podState.lastInsulinMeasurements)
+            case .deliveredInsulin:
+                cell.textLabel?.text = LocalizedString("Insulin Delivered", comment: "The title of the cell showing delivered insulin")
+                cell.setDeliveredInsulinDetail(podState.lastInsulinMeasurements)
+            default:
+                break
             }
-        case .configuration:
+            return cell
 
+        case .configuration:
             switch configurationRows[indexPath.row] {
             case .suspendResume:
                 return suspendResumeTableViewCell
             case .enableDisableConfirmationBeeps:
                 return confirmationBeepsTableViewCell
+            case .enableDisableAutoBolusBeeps:
+                return autoBolusBeepsTableViewCell
             case .reminder:
                 let cell = tableView.dequeueReusableCell(withIdentifier: ExpirationReminderDateTableViewCell.className, for: indexPath) as! ExpirationReminderDateTableViewCell
                 if let podState = podState, let reminderDate = pumpManager.expirationReminderDate {
@@ -436,7 +462,7 @@ class OmnipodSettingsViewController: RileyLinkSettingsViewController {
                     cell.textLabel?.text = LocalizedString("Pair New Pod", comment: "The title of the command to pair new pod")
                 } else if let podState = podState, podState.isFaulted {
                     cell.textLabel?.text = LocalizedString("Replace Pod Now", comment: "The title of the command to replace pod when there is a pod fault")
-                } else if let podState = podState, podState.unfinishedPairing {
+                } else if let podState = podState, podState.unfinishedSetup {
                     cell.textLabel?.text = LocalizedString("Finish pod setup", comment: "The title of the command to finish pod setup")
                 } else {
                     cell.textLabel?.text = LocalizedString("Replace Pod", comment: "The title of the command to replace pod")
@@ -447,67 +473,63 @@ class OmnipodSettingsViewController: RileyLinkSettingsViewController {
                 return cell
             }
             
-        case .status:
-            let podState = self.podState!
-            let statusRow = StatusRow(rawValue: indexPath.row)!
-            if statusRow == .alarms {
-                let cell = tableView.dequeueReusableCell(withIdentifier: AlarmsTableViewCell.className, for: indexPath) as! AlarmsTableViewCell
-                cell.textLabel?.text = LocalizedString("Alarms", comment: "The title of the cell showing alarm status")
-                cell.alerts = podState.activeAlerts
+        case .rileyLinks:
+            return super.tableView(tableView, cellForRowAt: indexPath)
+
+        case .diagnostics:
+            switch Diagnostics(rawValue: indexPath.row)! {
+            case .readPodStatus:
+                let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
+                cell.textLabel?.text = LocalizedString("Read Pod Status", comment: "The title of the command to read the pod status")
+                cell.accessoryType = .disclosureIndicator
                 return cell
-            } else {
+            case .playTestBeeps:
                 let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
-                
-                switch statusRow {
-                case .activatedAt:
-                    let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
-                    cell.textLabel?.text = LocalizedString("Active Time", comment: "The title of the cell showing the pod activated at time")
-                    cell.setDetailAge(podState.activatedAt?.timeIntervalSinceNow)
-                    return cell
-                case .expiresAt:
-                    let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
-                    if let expiresAt = podState.expiresAt {
-                        if expiresAt.timeIntervalSinceNow > 0 {
-                            cell.textLabel?.text = LocalizedString("Expires", comment: "The title of the cell showing the pod expiration")
-                        } else {
-                            cell.textLabel?.text = LocalizedString("Expired", comment: "The title of the cell showing the pod expiration after expiry")
-                        }
-                    }
-                    cell.setDetailDate(podState.expiresAt, formatter: dateFormatter)
-                    return cell
-                case .bolus:
-                    cell.textLabel?.text = LocalizedString("Bolus Delivery", comment: "The title of the cell showing pod bolus status")
-
-                    let deliveredUnits: Double?
-                    if let dose = podState.unfinalizedBolus {
-                        deliveredUnits = pumpManager.roundToSupportedBolusVolume(units: dose.progress * dose.units)
-                    } else {
-                        deliveredUnits = nil
-                    }
+                cell.textLabel?.text = LocalizedString("Play Test Beeps", comment: "The title of the command to play test beeps")
+                cell.accessoryType = .disclosureIndicator
+                return cell
+            case .readPulseLog:
+                let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
+                cell.textLabel?.text = LocalizedString("Read Pulse Log", comment: "The title of the command to read the pulse log")
+                cell.accessoryType = .disclosureIndicator
+                return cell
+            case .testCommand:
+                let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
+                cell.textLabel?.text = LocalizedString("Test Command", comment: "The title of the command to run the test command")
+                cell.accessoryType = .disclosureIndicator
+                return cell
+            }
 
-                    cell.setDetailBolus(suspended: podState.isSuspended, dose: podState.unfinalizedBolus, deliveredUnits: deliveredUnits)
-                    // TODO: This timer is in the wrong context; should be part of a custom bolus progress cell
-//                    if bolusProgressTimer == nil {
-//                        bolusProgressTimer = Timer.scheduledTimer(withTimeInterval: .seconds(2), repeats: true) { [weak self] (_) in
-//                            self?.tableView.reloadRows(at: [indexPath], with: .none)
-//                        }
-//                    }
-                case .basal:
-                    cell.textLabel?.text = LocalizedString("Basal Delivery", comment: "The title of the cell showing pod basal status")
-                    cell.setDetailBasal(suspended: podState.isSuspended, dose: podState.unfinalizedTempBasal)
-                case .reservoirLevel:
-                    cell.textLabel?.text = LocalizedString("Reservoir", comment: "The title of the cell showing reservoir status")
-                    cell.setReservoirDetail(podState.lastInsulinMeasurements)
-                case .deliveredInsulin:
-                    cell.textLabel?.text = LocalizedString("Insulin Delivered", comment: "The title of the cell showing delivered insulin")
-                    cell.setDeliveredInsulinDetail(podState.lastInsulinMeasurements)
-                default:
-                    break
-                }
+        case .podDetails:
+            let podState = self.podState!
+            switch PodDetailsRow(rawValue: indexPath.row)! {
+            case .podAddress:
+                let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
+                cell.textLabel?.text = LocalizedString("Assigned Address", comment: "The title text for the address assigned to the pod")
+                cell.detailTextLabel?.text = String(format:"%04X", podState.address)
+                return cell
+            case .podLot:
+                let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
+                cell.textLabel?.text = LocalizedString("Lot", comment: "The title of the cell showing the pod lot id")
+                cell.detailTextLabel?.text = String(format:"L%d", podState.lot)
+                return cell
+            case .podTid:
+                let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
+                cell.textLabel?.text = LocalizedString("TID", comment: "The title of the cell showing the pod TID")
+                cell.detailTextLabel?.text = String(format:"%07d", podState.tid)
+                return cell
+            case .piVersion:
+                let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
+                cell.textLabel?.text = LocalizedString("PI Version", comment: "The title of the cell showing the pod pi version")
+                cell.detailTextLabel?.text = podState.piVersion
+                return cell
+            case .pmVersion:
+                let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.className, for: indexPath)
+                cell.textLabel?.text = LocalizedString("PM Version", comment: "The title of the cell showing the pod pm version")
+                cell.detailTextLabel?.text = podState.pmVersion
                 return cell
             }
-        case .rileyLinks:
-            return super.tableView(tableView, cellForRowAt: indexPath)
+
         case .deletePumpManager:
             let cell = tableView.dequeueReusableCell(withIdentifier: TextButtonTableViewCell.className, for: indexPath) as! TextButtonTableViewCell
             
@@ -521,8 +543,6 @@ class OmnipodSettingsViewController: RileyLinkSettingsViewController {
     
     override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool {
         switch sections[indexPath.section] {
-        case .podDetails:
-            return false
         case .status:
             switch StatusRow(rawValue: indexPath.row)! {
             case .alarms:
@@ -530,12 +550,13 @@ class OmnipodSettingsViewController: RileyLinkSettingsViewController {
             default:
                 return false
             }
-        case .diagnostics, .configuration, .rileyLinks, .deletePumpManager:
+        case .configuration, .rileyLinks, .diagnostics, .deletePumpManager:
             return true
+        case .podDetails:
+            return false
         }
     }
 
-
     override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
         if indexPath == IndexPath(row: ConfigurationRow.reminder.rawValue, section: Section.configuration.rawValue) {
             tableView.beginUpdates()
@@ -547,27 +568,6 @@ class OmnipodSettingsViewController: RileyLinkSettingsViewController {
         let sender = tableView.cellForRow(at: indexPath)
         
         switch sections[indexPath.section] {
-        case .podDetails:
-            break
-        case .diagnostics:
-            switch Diagnostics(rawValue: indexPath.row)! {
-            case .readPodStatus:
-                let vc = CommandResponseViewController.readPodStatus(pumpManager: pumpManager)
-                vc.title = sender?.textLabel?.text
-                show(vc, sender: indexPath)
-            case .playTestBeeps:
-                let vc = CommandResponseViewController.playTestBeeps(pumpManager: pumpManager)
-                vc.title = sender?.textLabel?.text
-                show(vc, sender: indexPath)
-            case .readPulseLog:
-                let vc = CommandResponseViewController.readPulseLog(pumpManager: pumpManager)
-                vc.title = sender?.textLabel?.text
-                show(vc, sender: indexPath)
-            case .testCommand:
-                let vc = CommandResponseViewController.testingCommands(pumpManager: pumpManager)
-                vc.title = sender?.textLabel?.text
-                show(vc, sender: indexPath)
-            }
         case .status:
             switch StatusRow(rawValue: indexPath.row)! {
             case .alarms:
@@ -599,10 +599,12 @@ class OmnipodSettingsViewController: RileyLinkSettingsViewController {
             case .enableDisableConfirmationBeeps:
                 confirmationBeepsTapped()
                 tableView.deselectRow(at: indexPath, animated: true)
+            case .enableDisableAutoBolusBeeps:
+                autoBolusBeepsTapped()
+                tableView.deselectRow(at: indexPath, animated: true)
             case .reminder:
                 tableView.deselectRow(at: indexPath, animated: true)
                 tableView.endUpdates()
-                break
             case .timeZoneOffset:
                 let vc = CommandResponseViewController.changeTime(pumpManager: pumpManager)
                 vc.title = sender?.textLabel?.text
@@ -616,11 +618,11 @@ class OmnipodSettingsViewController: RileyLinkSettingsViewController {
                 show(vc, sender: sender)
             case .replacePod:
                 let vc: UIViewController
-                if podState == nil || podState!.setupProgress.primingNeeded {
-                    vc = PodReplacementNavigationController.instantiateNewPodFlow(pumpManager)
-                } else if let podState = podState, podState.isFaulted {
+                if let podState = podState, podState.isFaulted {
                     vc = PodReplacementNavigationController.instantiatePodReplacementFlow(pumpManager)
-                } else if let podState = podState, podState.unfinishedPairing {
+                } else if podState == nil || podState!.setupProgress.primingNeeded {
+                    vc = PodReplacementNavigationController.instantiateNewPodFlow(pumpManager)
+                } else if let podState = podState, podState.unfinishedSetup {
                     vc = PodReplacementNavigationController.instantiateInsertCannulaFlow(pumpManager)
                 } else {
                     vc = PodReplacementNavigationController.instantiatePodReplacementFlow(pumpManager)
@@ -632,8 +634,42 @@ class OmnipodSettingsViewController: RileyLinkSettingsViewController {
             }
         case .rileyLinks:
             let device = devicesDataSource.devices[indexPath.row]
-            let vc = RileyLinkDeviceTableViewController(device: device)
+            
+            guard device.hardwareType != nil else {
+                tableView.deselectRow(at: indexPath, animated: true)
+                return
+            }
+
+            let vc = RileyLinkDeviceTableViewController(
+                device: device,
+                batteryAlertLevel: pumpManager.rileyLinkBatteryAlertLevel,
+                batteryAlertLevelChanged: { [weak self] value in
+                    self?.pumpManager.rileyLinkBatteryAlertLevel = value
+                }
+            )
+            
             self.show(vc, sender: sender)
+        case .diagnostics:
+            switch Diagnostics(rawValue: indexPath.row)! {
+            case .readPodStatus:
+                let vc = CommandResponseViewController.readPodStatus(pumpManager: pumpManager)
+                vc.title = sender?.textLabel?.text
+                show(vc, sender: indexPath)
+            case .playTestBeeps:
+                let vc = CommandResponseViewController.playTestBeeps(pumpManager: pumpManager)
+                vc.title = sender?.textLabel?.text
+                show(vc, sender: indexPath)
+            case .readPulseLog:
+                let vc = CommandResponseViewController.readPulseLog(pumpManager: pumpManager)
+                vc.title = sender?.textLabel?.text
+                show(vc, sender: indexPath)
+            case .testCommand:
+                let vc = CommandResponseViewController.testingCommands(pumpManager: pumpManager)
+                vc.title = sender?.textLabel?.text
+                show(vc, sender: indexPath)
+            }
+        case .podDetails:
+            break
         case .deletePumpManager:
             let confirmVC = UIAlertController(pumpManagerDeletionHandler: {
                 self.pumpManager.notifyDelegateOfDeactivation {
@@ -651,23 +687,20 @@ class OmnipodSettingsViewController: RileyLinkSettingsViewController {
     
     override func tableView(_ tableView: UITableView, willDeselectRowAt indexPath: IndexPath) -> IndexPath? {
         switch sections[indexPath.section] {
-        case .podDetails, .status:
+        case .status:
             break
-        case .diagnostics:
-            switch Diagnostics(rawValue: indexPath.row)! {
-            case .readPodStatus, .playTestBeeps, .readPulseLog, .testCommand:
-                tableView.reloadRows(at: [indexPath], with: .fade)
-            }
         case .configuration:
             switch configurationRows[indexPath.row] {
-            case .suspendResume, .enableDisableConfirmationBeeps, .reminder:
+            case .suspendResume, .enableDisableConfirmationBeeps, .enableDisableAutoBolusBeeps, .reminder:
                 break
             case .timeZoneOffset, .replacePod, .insulinType:
                 tableView.reloadRows(at: [indexPath], with: .fade)
             }
         case .rileyLinks:
             break
-        case .deletePumpManager:
+        case .diagnostics:
+            tableView.reloadRows(at: [indexPath], with: .fade)
+        case .podDetails, .deletePumpManager:
             break
         }
         
@@ -699,39 +732,59 @@ class OmnipodSettingsViewController: RileyLinkSettingsViewController {
         }
     }
 
-    private func confirmationBeepsTapped() {
-        let confirmationBeeps: Bool = pumpManager.confirmationBeeps
-        
+    private func setConfirmationBeeps(confirmationBeeps: Bool) {
         func done() {
             DispatchQueue.main.async { [weak self] in
                 if let self = self {
                     self.confirmationBeepsTableViewCell.updateTextLabel(enabled: self.pumpManager.confirmationBeeps)
                     self.confirmationBeepsTableViewCell.isLoading = false
+                    self.autoBolusBeepsTableViewCell.isEnabled = self.pumpManager.confirmationBeeps
                 }
             }
         }
 
         confirmationBeepsTableViewCell.isLoading = true
-        if confirmationBeeps {
-            pumpManager.setConfirmationBeeps(enabled: false, completion: { (error) in
-                if let error = error {
-                    DispatchQueue.main.async {
-                        let title = LocalizedString("Error disabling confirmation beeps", comment: "The alert title for disable confirmation beeps error")
-                        self.present(UIAlertController(with: error, title: title), animated: true)
+        pumpManager.setConfirmationBeeps(enabled: confirmationBeeps, completion: { (error) in
+            if let error = error {
+                DispatchQueue.main.async {
+                    let title: String
+                    if confirmationBeeps {
+                        title = LocalizedString("Error enabling confirmation beeps", comment: "The alert title for enable confirmation beeps error")
+                    } else {
+                        title = LocalizedString("Error disabling confirmation beeps", comment: "The alert title for disable confirmation beeps error")
                     }
+                    self.present(UIAlertController(with: error, title: title), animated: true)
                 }
-                done()
-            })
-        } else {
-            pumpManager.setConfirmationBeeps(enabled: true, completion: { (error) in
-                if let error = error {
-                    DispatchQueue.main.async {
-                        let title = LocalizedString("Error enabling confirmation beeps", comment: "The alert title for enable confirmation beeps error")
-                        self.present(UIAlertController(with: error, title: title), animated: true)
-                    }
+            }
+            done()
+        })
+    }
+
+    private func confirmationBeepsTapped() {
+        setConfirmationBeeps(confirmationBeeps: !pumpManager.confirmationBeeps)
+    }
+
+    private func autoBolusBeepsTapped() {
+        let newValue = !pumpManager.automaticBolusBeeps
+        pumpManager.automaticBolusBeeps = newValue
+
+        func done() {
+            DispatchQueue.main.async { [weak self] in
+                if let self = self {
+                    self.autoBolusBeepsTableViewCell.updateTextLabel(enabled: newValue)
+                    self.autoBolusBeepsTableViewCell.isLoading = false
                 }
-                done()
+            }
+        }
+
+        // Beep if confirmation beeps are enabled else just update the value displayed
+        if pumpManager.confirmationBeeps {
+            self.autoBolusBeepsTableViewCell.isLoading = true
+            pumpManager.setConfirmationBeeps(enabled: true, completion: { (error) in
+                done() // no worries if confirmation beep fails for any reason
             })
+        } else {
+            self.autoBolusBeepsTableViewCell.updateTextLabel(enabled: newValue)
         }
     }
 }
@@ -786,7 +839,7 @@ extension OmnipodSettingsViewController: PodStateObserver {
             return
         }
 
-        let reloadRows: [StatusRow] = [.bolus, .basal, .reservoirLevel, .deliveredInsulin]
+        let reloadRows: [StatusRow] = [.podActiveClock, .bolus, .basal, .reservoirLevel, .deliveredInsulin]
         self.tableView.reloadRows(at: reloadRows.map({ IndexPath(row: $0.rawValue, section: statusIdx) }), with: .none)
 
         if oldState?.activeAlerts != state?.activeAlerts,
@@ -839,14 +892,15 @@ private extension TimeInterval {
     func format(using units: NSCalendar.Unit) -> String? {
         let formatter = DateComponentsFormatter()
         formatter.allowedUnits = units
-        formatter.unitsStyle = .full
+        formatter.unitsStyle = .short
         formatter.zeroFormattingBehavior = .dropLeading
         formatter.maximumUnitCount = 2
-        
+
         return formatter.string(from: self)
     }
 }
 
+
 class AlarmsTableViewCell: LoadingTableViewCell {
     
     private var defaultDetailColor: UIColor?
@@ -930,7 +984,7 @@ private extension UITableViewCell {
     
     func setDetailAge(_ age: TimeInterval?) {
         if let age = age {
-            detailTextLabel?.text = fabs(age).format(using: [.day, .hour, .minute])
+            detailTextLabel?.text = fabs(age).format(using: [.hour, .minute])
         } else {
             detailTextLabel?.text = ""
         }
@@ -964,8 +1018,6 @@ private extension UITableViewCell {
                 self.detailTextLabel?.text = String(format: LocalizedString("%@ U of %@ U (%@)", comment: "Format string for bolus progress. (1: The delivered amount) (2: The programmed amount) (3: the percent progress)"), deliveredUnits, units, progressStr)
             }
         }
-
-
     }
     
     func setDeliveredInsulinDetail(_ measurements: PodInsulinMeasurements?) {

+ 15 - 6
Dependencies/rileylink_ios/OmniKitUI/ViewControllers/PairPodSetupViewController.swift

@@ -134,7 +134,17 @@ class PairPodSetupViewController: SetupTableViewController {
                 errorStrings = [lastError?.localizedDescription].compactMap { $0 }
             }
             
-            if let commsError = lastError as? PodCommsError, commsError.possibleWeakCommsCause {
+            var podCommsError: PodCommsError? = nil
+            if let pumpManagerError = lastError as? PumpManagerError {
+                switch pumpManagerError {
+                case .communication(let error):
+                    podCommsError = error as? PodCommsError
+                default:
+                    break
+                }
+            }
+
+            if let podCommsError = podCommsError, podCommsError.possibleWeakCommsCause {
                 if previouslyEncounteredWeakComms {
                     errorStrings.append(LocalizedString("If the problem persists, move to a new area and try again", comment: "Additional pairing recovery suggestion on multiple pairing failures"))
                 } else {
@@ -152,12 +162,11 @@ class PairPodSetupViewController: SetupTableViewController {
             }
             loadingText = errorText
             
-            // If we have an error, update the continue state
-            if let podCommsError = lastError as? PodCommsError {
-                switch podCommsError {
-                case .podFault, .activationTimeExceeded:
+            // If we have an error, update the continue state appropriately
+            if let podCommsError = podCommsError {
+                if podCommsError.isFaulted {
                     continueState = .fault
-                default:
+                } else {
                     continueState = .initial
                 }
             } else if lastError != nil {

+ 7 - 0
Dependencies/rileylink_ios/OmniKitUI/ViewControllers/ReplacePodViewController.swift

@@ -16,6 +16,7 @@ class ReplacePodViewController: SetupTableViewController {
     enum PodReplacementReason {
         case normal
         case activationTimeout
+        case podIncompatible
         case fault(_ podFault: DetailedStatus)
         case canceledPairingBeforeApplication
         case canceledPairing
@@ -29,6 +30,8 @@ class ReplacePodViewController: SetupTableViewController {
                 break // Text set in interface builder
             case .activationTimeout:
                 instructionsLabel.text = LocalizedString("Activation time exceeded. The pod must be deactivated before pairing with a new one. Please deactivate and discard pod.", comment: "Instructions when deactivating pod that didn't complete activation in time.")
+            case .podIncompatible:
+                instructionsLabel.text = LocalizedString("Unable to use incompatible pod. The pod must be deactivated before pairing with a new one. Please deactivate and discard pod.", comment: "Instructions when deactivating an incompatible pod")
             case .fault(let podFault):
                 var faultDescription = podFault.faultEventCode.localizedDescription
                 if let refString = podFault.pdmRef {
@@ -55,6 +58,10 @@ class ReplacePodViewController: SetupTableViewController {
                 } else {
                     self.replacementReason = .fault(podFault)
                 }
+            } else if podState?.setupProgress == .activationTimeout {
+                self.replacementReason = .activationTimeout
+            } else if podState?.setupProgress == .podIncompatible {
+                self.replacementReason = .podIncompatible
             } else if podState?.setupProgress.primingNeeded == true {
                 self.replacementReason = .canceledPairingBeforeApplication
             } else if podState?.setupProgress.needsCannulaInsertion == true {

+ 4 - 0
Dependencies/rileylink_ios/RileyLink.xcodeproj/project.pbxproj

@@ -249,6 +249,7 @@
 		7D9BF00123369910005DCFD6 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D9BF00323369910005DCFD6 /* Localizable.strings */; };
 		7D9BF03D2336AE0B005DCFD6 /* OmnipodPumpManager.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 7D9BF03F2336AE0B005DCFD6 /* OmnipodPumpManager.storyboard */; };
 		7DEFE05322ED1C2400FCD378 /* OverrideStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DEFE05222ED1C2300FCD378 /* OverrideStatus.swift */; };
+		B62DBD572725B9EB0050C038 /* CommandResponseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B62DBD562725B9EB0050C038 /* CommandResponseViewController.swift */; };
 		C104A9C1217E603E006E3C3E /* OmnipodReservoirView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C104A9BF217E603E006E3C3E /* OmnipodReservoirView.swift */; };
 		C104A9C2217E603E006E3C3E /* OmnipodReservoirView.xib in Resources */ = {isa = PBXBuildFile; fileRef = C104A9C0217E603E006E3C3E /* OmnipodReservoirView.xib */; };
 		C104A9C3217E611F006E3C3E /* NibLoadable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14FFC5A1D3D74F90049CF85 /* NibLoadable.swift */; };
@@ -1207,6 +1208,7 @@
 		7D9BF15423371408005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = "<group>"; };
 		7D9BF15523371408005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = "<group>"; };
 		7DEFE05222ED1C2300FCD378 /* OverrideStatus.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideStatus.swift; sourceTree = "<group>"; };
+		B62DBD562725B9EB0050C038 /* CommandResponseViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommandResponseViewController.swift; sourceTree = "<group>"; };
 		C104A9BF217E603E006E3C3E /* OmnipodReservoirView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OmnipodReservoirView.swift; sourceTree = "<group>"; };
 		C104A9C0217E603E006E3C3E /* OmnipodReservoirView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = OmnipodReservoirView.xib; sourceTree = "<group>"; };
 		C104A9C4217E645C006E3C3E /* HUDAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = HUDAssets.xcassets; sourceTree = "<group>"; };
@@ -1879,6 +1881,7 @@
 				C170C98D1CECD6F300F3D8E5 /* CBPeripheralState.swift */,
 				439731261CF21C3C00F474E5 /* RileyLinkDeviceTableViewCell.swift */,
 				C170C9981CECD80000F3D8E5 /* RileyLinkDeviceTableViewController.swift */,
+				B62DBD562725B9EB0050C038 /* CommandResponseViewController.swift */,
 				435D26B320DA0AAE00891C17 /* RileyLinkDevicesHeaderView.swift */,
 				435D26B520DA0BCC00891C17 /* RileyLinkDevicesTableViewDataSource.swift */,
 				43709ABB20DF1C6400F941B3 /* RileyLinkManagerSetupViewController.swift */,
@@ -3632,6 +3635,7 @@
 				43709ABE20DF1C6400F941B3 /* RileyLinkManagerSetupViewController.swift in Sources */,
 				43D5E7A01FAF7CCA004ACDB7 /* NumberFormatter.swift in Sources */,
 				43709AE420DF20D500F941B3 /* OSLog.swift in Sources */,
+				B62DBD572725B9EB0050C038 /* CommandResponseViewController.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};

+ 13 - 4
Dependencies/rileylink_ios/RileyLinkBLEKit/CommandSession.swift

@@ -146,10 +146,19 @@ public struct CommandSession {
 
     
     /// - Throws: RileyLinkDeviceError
-    public func enableCCLEDs() throws {
-        let enableBlue = SetLEDMode(.blue, mode: .auto)
+    public func setCCLEDMode(_ mode: RileyLinkLEDMode) throws {
+        let ccMode: RileyLinkLEDMode
+        
+        switch mode {
+        case .on:
+            ccMode = .auto
+        default:
+            ccMode = .off
+        }
+        
+        let enableBlue = SetLEDMode(.blue, mode: ccMode)
         _ = try writeCommand(enableBlue, timeout: 0)
-        let enableGreen = SetLEDMode(.green, mode: .auto)
+        let enableGreen = SetLEDMode(.green, mode: ccMode)
         _ = try writeCommand(enableGreen, timeout: 0)
     }
 
@@ -177,7 +186,7 @@ public struct CommandSession {
 
         return Measurement<UnitFrequency>(value: frequency, unit: .hertz).converted(to: .megahertz)
     }
-
+    
     /// Sends data to the pump, listening for a reply
     ///
     /// - Parameters:

+ 247 - 16
Dependencies/rileylink_ios/RileyLinkBLEKit/PeripheralManager+RileyLink.swift

@@ -12,13 +12,17 @@ import os.log
 protocol CBUUIDRawValue: RawRepresentable {}
 extension CBUUIDRawValue where RawValue == String {
     var cbUUID: CBUUID {
-        return CBUUID(string: rawValue)
+        return CBUUID(string: rawValue.uppercased())
     }
 }
 
 
 enum RileyLinkServiceUUID: String, CBUUIDRawValue {
-    case main = "0235733B-99C5-4197-B856-69219C2A3845"
+    case main
+            = "0235733B-99C5-4197-B856-69219C2A3845"
+    case battery   = "180F"
+    case orange    = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"
+    case secureDFU = "FE59"
 }
 
 enum MainServiceCharacteristicUUID: String, CBUUIDRawValue {
@@ -30,7 +34,42 @@ enum MainServiceCharacteristicUUID: String, CBUUIDRawValue {
     case ledMode         = "C6D84241-F1A7-4F9C-A25F-FCE16732F14E"
 }
 
-enum RileyLinkLEDMode: UInt8 {
+enum BatteryServiceCharacteristicUUID: String, CBUUIDRawValue {
+    case battery_level   = "2A19"
+}
+
+enum OrangeServiceCharacteristicUUID: String, CBUUIDRawValue {
+    case orangeRX = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E"
+    case orangeTX = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"
+}
+
+enum SecureDFUCharacteristicUUID: String, CBUUIDRawValue {
+    case control = "8EC90001-F315-4F60-9FB8-838830DAEA50"
+}
+
+
+public enum OrangeLinkCommand: UInt8 {
+    case yellow   = 0x1
+    case red      = 0x2
+    case off      = 0x3
+    case shake    = 0x4
+    case shakeOff = 0x5
+    case fw_hw    = 0x9
+}
+
+public enum OrangeLinkRequestType: UInt8 {
+    case fctStartLoop = 0xaa // Fct_StartLoop
+    case fctHeader = 0xbb    // Fct_PutReq
+    case fctStopLoop = 0xcc  // Fct_StopLoop
+    case cfgHeader = 0xdd    // Cfg_PutReq
+}
+
+public enum OrangeLinkConfigurationSetting: UInt8 {
+    case connectionLED     = 0x00
+    case connectionVibrate = 0x01
+}
+
+public enum RileyLinkLEDMode: UInt8 {
     case off  = 0x00
     case on   = 0x01
     case auto = 0x02
@@ -48,12 +87,25 @@ extension PeripheralManager.Configuration {
                     MainServiceCharacteristicUUID.timerTick.cbUUID,
                     MainServiceCharacteristicUUID.firmwareVersion.cbUUID,
                     MainServiceCharacteristicUUID.ledMode.cbUUID
+                ],
+                RileyLinkServiceUUID.battery.cbUUID: [
+                    BatteryServiceCharacteristicUUID.battery_level.cbUUID
+                ],
+                RileyLinkServiceUUID.orange.cbUUID: [
+                    OrangeServiceCharacteristicUUID.orangeRX.cbUUID,
+                    OrangeServiceCharacteristicUUID.orangeTX.cbUUID,
+                ],
+                RileyLinkServiceUUID.secureDFU.cbUUID: [
+                    SecureDFUCharacteristicUUID.control.cbUUID,
                 ]
+
             ],
             notifyingCharacteristics: [
                 RileyLinkServiceUUID.main.cbUUID: [
                     MainServiceCharacteristicUUID.responseCount.cbUUID
-                    // TODO: Should timer tick default to on?
+                ],
+                RileyLinkServiceUUID.orange.cbUUID: [
+                    OrangeServiceCharacteristicUUID.orangeTX.cbUUID,
                 ]
             ],
             valueUpdateMacros: [
@@ -71,8 +123,23 @@ extension PeripheralManager.Configuration {
     }
 }
 
-
 fileprivate extension CBPeripheral {
+    func getBatteryCharacteristic(_ uuid: BatteryServiceCharacteristicUUID) -> CBCharacteristic? {
+        guard let service = services?.itemWithUUID(RileyLinkServiceUUID.battery.cbUUID) else {
+            return nil
+        }
+
+        return service.characteristics?.itemWithUUID(uuid.cbUUID)
+    }
+    
+    func getOrangeCharacteristic(_ uuid: OrangeServiceCharacteristicUUID) -> CBCharacteristic? {
+        guard let service = services?.itemWithUUID(RileyLinkServiceUUID.orange.cbUUID) else {
+            return nil
+        }
+
+        return service.characteristics?.itemWithUUID(uuid.cbUUID)
+    }
+    
     func getCharacteristicWithUUID(_ uuid: MainServiceCharacteristicUUID, serviceUUID: RileyLinkServiceUUID = .main) -> CBCharacteristic? {
         guard let service = services?.itemWithUUID(serviceUUID.cbUUID) else {
             return nil
@@ -113,6 +180,44 @@ private let log = OSLog(category: "PeripheralManager+RileyLink")
 
 extension PeripheralManager {
     static let expectedMaxBLELatency: TimeInterval = 2
+    
+    func readBatteryLevel(completion: @escaping (Int?) -> Void) {
+        perform { (manager) in
+            guard let characteristic = self.peripheral.getBatteryCharacteristic(.battery_level) else {
+                completion(nil)
+                return
+            }
+            
+            do {
+                guard let data = try self.readValue(for: characteristic, timeout: PeripheralManager.expectedMaxBLELatency) else {
+                    completion(nil)
+                    return
+                }
+                
+                completion(Int(data[0]))
+            } catch {
+                completion(nil)
+            }
+        }
+    }
+    
+    func readDiagnosticLEDMode(completion: @escaping (RileyLinkLEDMode?) -> Void) {
+        perform { (manager) in
+            do {
+                guard
+                    let characteristic = self.peripheral.getCharacteristicWithUUID(.ledMode),
+                    let data = try self.readValue(for: characteristic, timeout: PeripheralManager.expectedMaxBLELatency),
+                    let mode = RileyLinkLEDMode(rawValue: data[0]) else
+                {
+                    completion(nil)
+                    return
+                }
+                completion(mode)
+            } catch {
+                completion(nil)
+            }
+        }
+    }
 
     var timerTickEnabled: Bool {
         return peripheral.getCharacteristicWithUUID(.timerTick)?.isNotifying ?? false
@@ -122,7 +227,7 @@ extension PeripheralManager {
         perform { (manager) in
             do {
                 guard let characteristic = manager.peripheral.getCharacteristicWithUUID(.timerTick) else {
-                    throw PeripheralManagerError.unknownCharacteristic
+                    throw PeripheralManagerError.unknownCharacteristic(MainServiceCharacteristicUUID.timerTick.cbUUID)
                 }
 
                 try manager.setNotifyValue(enabled, for: characteristic, timeout: timeout)
@@ -139,7 +244,7 @@ extension PeripheralManager {
         perform { (manager) in
             do {
                 guard let characteristic = manager.peripheral.getCharacteristicWithUUID(.ledMode) else {
-                    throw PeripheralManagerError.unknownCharacteristic
+                    throw PeripheralManagerError.unknownCharacteristic(MainServiceCharacteristicUUID.ledMode.cbUUID)
                 }
                 let value = Data([mode.rawValue])
                 try manager.writeValue(value, for: characteristic, type: .withResponse, timeout: PeripheralManager.expectedMaxBLELatency)
@@ -175,7 +280,7 @@ extension PeripheralManager {
         perform { (manager) in
             do {
                 guard let characteristic = manager.peripheral.getCharacteristicWithUUID(.customName) else {
-                    throw PeripheralManagerError.unknownCharacteristic
+                    throw PeripheralManagerError.unknownCharacteristic(MainServiceCharacteristicUUID.customName.cbUUID)
                 }
 
                 try manager.writeValue(value, for: characteristic, type: .withResponse, timeout: timeout)
@@ -211,7 +316,7 @@ extension PeripheralManager {
     ///     - RileyLinkDeviceError.writeSizeLimitExceeded
     func writeCommand<C: Command>(_ command: C, timeout: TimeInterval, responseType: ResponseType) throws -> C.ResponseType {
         guard let characteristic = peripheral.getCharacteristicWithUUID(.data) else {
-            throw RileyLinkDeviceError.peripheralManagerError(.unknownCharacteristic)
+            throw RileyLinkDeviceError.peripheralManagerError(PeripheralManagerError.unknownCharacteristic(MainServiceCharacteristicUUID.data.cbUUID))
         }
 
         let value = try command.writableData()
@@ -245,7 +350,7 @@ extension PeripheralManager {
     ///     - RileyLinkDeviceError.writeSizeLimitExceeded
     fileprivate func writeCommandWithoutResponse<C: Command>(_ command: C, timeout: TimeInterval) throws {
         guard let characteristic = peripheral.getCharacteristicWithUUID(.data) else {
-            throw RileyLinkDeviceError.peripheralManagerError(.unknownCharacteristic)
+            throw RileyLinkDeviceError.peripheralManagerError(PeripheralManagerError.unknownCharacteristic(MainServiceCharacteristicUUID.data.cbUUID))
         }
 
         let value = try command.writableData()
@@ -272,13 +377,12 @@ extension PeripheralManager {
     ///     - RileyLinkDeviceError.peripheralManagerError
     func readBluetoothFirmwareVersion(timeout: TimeInterval) throws -> String {
         guard let characteristic = peripheral.getCharacteristicWithUUID(.firmwareVersion) else {
-            throw RileyLinkDeviceError.peripheralManagerError(.unknownCharacteristic)
+            throw RileyLinkDeviceError.peripheralManagerError(PeripheralManagerError.unknownCharacteristic(MainServiceCharacteristicUUID.firmwareVersion.cbUUID))
         }
 
         do {
             guard let data = try readValue(for: characteristic, timeout: timeout) else {
-                // TODO: This is an "unknown value" issue, not a timeout
-                throw RileyLinkDeviceError.peripheralManagerError(.timeout)
+                throw RileyLinkDeviceError.peripheralManagerError(PeripheralManagerError.emptyValue)
             }
 
             guard let version = String(bytes: data, encoding: .utf8) else {
@@ -295,7 +399,126 @@ extension PeripheralManager {
 
 // MARK: - Lower-level helper operations
 extension PeripheralManager {
-
+    
+    func setOrangeNotifyOn() throws {
+        perform { [self] (manager) in
+            guard let characteristicNotif = peripheral.getOrangeCharacteristic(.orangeTX) else {
+                return
+            }
+            
+            do {
+                try setNotifyValue(true, for: characteristicNotif, timeout: 2)
+            } catch {
+                log.error("setOrangeNotifyOn failed: %@", error.localizedDescription)
+            }
+        }
+    }
+    
+    func orangeAction(_ command: OrangeLinkCommand) {
+        if command != .off, command != .shakeOff {
+            orangeWritePwd()
+        }
+        perform { [self] (manager) in
+            do {
+                guard let characteristic = peripheral.getOrangeCharacteristic(.orangeRX) else {
+                    throw PeripheralManagerError.unknownCharacteristic(OrangeServiceCharacteristicUUID.orangeRX.cbUUID)
+                }
+                let value = Data([OrangeLinkRequestType.fctHeader.rawValue, command.rawValue])
+                try writeValue(value, for: characteristic, type: .withResponse, timeout: PeripheralManager.expectedMaxBLELatency)
+            } catch (_) {
+                log.debug("orangeAction failed")
+            }
+        }
+        if command == .off, command == .shakeOff {
+            orangeClose()
+        }
+    }
+    
+    func findDevice() {
+        perform { [self] (manager) in
+            do {
+                guard let characteristic = peripheral.getOrangeCharacteristic(.orangeRX) else {
+                    throw PeripheralManagerError.unknownCharacteristic(OrangeServiceCharacteristicUUID.orangeRX.cbUUID)
+                }
+                let value = Data([OrangeLinkRequestType.cfgHeader.rawValue, 0x04])
+                try writeValue(value, for: characteristic, type: .withResponse, timeout: PeripheralManager.expectedMaxBLELatency)
+            } catch (_) {
+                log.debug("findDevice failed")
+            }
+        }
+    }
+    
+    func setOrangeConfig(_ config: OrangeLinkConfigurationSetting, isOn: Bool) {
+        perform { [self] (manager) in
+            do {
+                guard let characteristic = peripheral.getOrangeCharacteristic(.orangeRX) else {
+                    throw PeripheralManagerError.unknownCharacteristic(OrangeServiceCharacteristicUUID.orangeRX.cbUUID)
+                }
+                let value = Data([OrangeLinkRequestType.cfgHeader.rawValue, 0x02, config.rawValue, isOn ? 1 : 0])
+                try writeValue(value, for: characteristic, type: .withResponse, timeout: PeripheralManager.expectedMaxBLELatency)
+            } catch (_) {
+                log.debug("setOrangeConfig failed")
+            }
+        }
+    }
+    
+    func orangeWritePwd() {
+        perform { [self] (manager) in
+            do {
+                guard let characteristic = peripheral.getOrangeCharacteristic(.orangeRX) else {
+                    throw PeripheralManagerError.unknownCharacteristic(OrangeServiceCharacteristicUUID.orangeRX.cbUUID)
+                }
+                let value = Data([0xAA])
+                try writeValue(value, for: characteristic, type: .withResponse, timeout: PeripheralManager.expectedMaxBLELatency)
+            } catch (_) {
+                log.debug("orangeWritePwd failed")
+            }
+        }
+    }
+    
+    func orangeReadSet() {
+        perform { [self] (manager) in
+            do {
+                guard let characteristic = peripheral.getOrangeCharacteristic(.orangeRX) else {
+                    throw PeripheralManagerError.unknownCharacteristic(OrangeServiceCharacteristicUUID.orangeRX.cbUUID)
+                }
+                let value = Data([OrangeLinkRequestType.cfgHeader.rawValue, 0x01])
+                log.debug("orangeReadSet write: %@", value.hexadecimalString)
+                try writeValue(value, for: characteristic, type: .withResponse, timeout: PeripheralManager.expectedMaxBLELatency)
+            } catch (_) {
+                log.debug("orangeReadSet failed")
+            }
+        }
+    }
+    
+    func orangeReadVDC() {
+        perform { [self] (manager) in
+            do {
+                guard let characteristic = peripheral.getOrangeCharacteristic(.orangeRX) else {
+                    throw PeripheralManagerError.unknownCharacteristic(OrangeServiceCharacteristicUUID.orangeRX.cbUUID)
+                }
+                let value = Data([OrangeLinkRequestType.cfgHeader.rawValue, 0x03])
+                try writeValue(value, for: characteristic, type: .withResponse, timeout: PeripheralManager.expectedMaxBLELatency)
+            } catch (_) {
+                log.debug("orangeReadVDC failed")
+            }
+        }
+    }
+    
+    func orangeClose() {
+        perform { [self] (manager) in
+            do {
+                guard let characteristic = peripheral.getOrangeCharacteristic(.orangeRX) else {
+                    throw PeripheralManagerError.unknownCharacteristic(OrangeServiceCharacteristicUUID.orangeRX.cbUUID)
+                }
+                let value = Data([0xcc])
+                try writeValue(value, for: characteristic, type: .withResponse, timeout: PeripheralManager.expectedMaxBLELatency)
+            } catch (_) {
+                log.debug("orangeClose failed")
+            }
+        }
+    }
+    
     /// Writes command data expecting a single response
     ///
     /// - Parameters:
@@ -342,7 +565,7 @@ extension PeripheralManager {
                         return false
                     default:
                         guard let response = R(data: value) else {
-                            log.error("Unable to parse response.")
+                            log.error("Unable to parse response: %{public}@", value.hexadecimalString)
                             // We don't recognize the contents. Keep listening.
                             return false
                         }
@@ -355,7 +578,15 @@ extension PeripheralManager {
                 peripheral.writeValue(data, for: characteristic, type: type)
             }
         } catch let error as PeripheralManagerError {
-            throw RileyLinkDeviceError.peripheralManagerError(error)
+            // If the write succeeded, but we get no response, BLE comms are working but RL command channel is hung
+            if case .timeout(let unmetConditions) = error,
+               let firstUnmetCondition = unmetConditions.first,
+               case .valueUpdate = firstUnmetCondition
+            {
+                throw RileyLinkDeviceError.commandsBlocked
+            } else {
+                throw RileyLinkDeviceError.peripheralManagerError(error)
+            }
         }
 
         guard let response = capturedResponse else {

+ 26 - 35
Dependencies/rileylink_ios/RileyLinkBLEKit/PeripheralManager.swift

@@ -51,7 +51,7 @@ class PeripheralManager: NSObject {
 
     // Confined to `queue`
     private var needsConfiguration = true
-
+    
     weak var delegate: PeripheralManagerDelegate? {
         didSet {
             queue.sync {
@@ -59,7 +59,7 @@ class PeripheralManager: NSObject {
             }
         }
     }
-
+    
     // Called from RileyLinkDeviceManager.managerQueue
     init(peripheral: CBPeripheral, configuration: Configuration, centralManager: CBCentralManager, queue: DispatchQueue) {
         self.peripheral = peripheral
@@ -93,8 +93,10 @@ extension PeripheralManager {
     }
 }
 
-protocol PeripheralManagerDelegate: class {
+protocol PeripheralManagerDelegate: AnyObject {
     func peripheralManager(_ manager: PeripheralManager, didUpdateValueFor characteristic: CBCharacteristic)
+    
+    func peripheralManager(_ manager: PeripheralManager, didUpdateNotificationStateFor characteristic: CBCharacteristic)
 
     func peripheralManager(_ manager: PeripheralManager, didReadRSSI RSSI: NSNumber, error: Error?)
 
@@ -108,17 +110,13 @@ protocol PeripheralManagerDelegate: class {
 extension PeripheralManager {
     func configureAndRun(_ block: @escaping (_ manager: PeripheralManager) -> Void) -> (() -> Void) {
         return {
-            // TODO: Accessing self might be a race on initialization
-            if !self.needsConfiguration && self.peripheral.services == nil {
-                self.log.error("Configured peripheral has no services. Reconfiguring…")
-            }
-            
             if self.needsConfiguration || self.peripheral.services == nil {
+                self.log.default("Configuring peripheral %{public}@, needsConfiguration=%{public}@, has services = %{public}@", self.peripheral, String(describing: self.needsConfiguration), String(describing:  self.peripheral.services != nil))
                 do {
                     try self.applyConfiguration()
-                    self.log.default("Peripheral configuration completed")
+                    self.log.default("Peripheral configuration completed: %{public}@", self.peripheral)
                 } catch let error {
-                    self.log.error("Error applying peripheral configuration: %@", String(describing: error))
+                    self.log.error("Error applying peripheral configuration: %{public}@", String(describing: error))
                     // Will retry
                 }
 
@@ -131,7 +129,7 @@ extension PeripheralManager {
                         self.log.error("No delegate set for configuration")
                     }
                 } catch let error {
-                    self.log.error("Error applying delegate configuration: %@", String(describing: error))
+                    self.log.error("Error applying delegate configuration: %{public}@", String(describing: error))
                     // Will retry
                 }
             }
@@ -145,8 +143,10 @@ extension PeripheralManager {
     }
 
     private func assertConfiguration() {
-        perform { (_) in
-            // Intentionally empty to trigger configuration if necessary
+        if peripheral.state == .connected {
+            perform { (_) in
+                // Intentionally empty to trigger configuration if necessary
+            }
         }
     }
 
@@ -155,21 +155,23 @@ extension PeripheralManager {
 
         for service in peripheral.services ?? [] {
             guard let characteristics = configuration.serviceCharacteristics[service.uuid] else {
-                // Not all services may have characteristics
+                // Not all services have characteristics
                 continue
             }
 
             try discoverCharacteristics(characteristics, for: service, timeout: discoveryTimeout)
         }
 
+        // Subscribe to notifying characteristics
         for (serviceUUID, characteristicUUIDs) in configuration.notifyingCharacteristics {
             guard let service = peripheral.services?.itemWithUUID(serviceUUID) else {
-                throw PeripheralManagerError.unknownCharacteristic
+                // Not all RL's have OrangeLink service
+                continue
             }
 
             for characteristicUUID in characteristicUUIDs {
                 guard let characteristic = service.characteristics?.itemWithUUID(characteristicUUID) else {
-                    throw PeripheralManagerError.unknownCharacteristic
+                    throw PeripheralManagerError.unknownCharacteristic(characteristicUUID)
                 }
 
                 guard !characteristic.isNotifying else {
@@ -200,7 +202,7 @@ extension PeripheralManager {
         }
 
         guard commandConditions.isEmpty else {
-            throw PeripheralManagerError.notReady
+            throw PeripheralManagerError.busy
         }
 
         // Run
@@ -220,7 +222,7 @@ extension PeripheralManager {
         }
 
         guard signaled else {
-            throw PeripheralManagerError.timeout
+            throw PeripheralManagerError.timeout(commandConditions)
         }
 
         if let error = commandError {
@@ -284,18 +286,6 @@ extension PeripheralManager {
         return characteristic.value
     }
 
-    /// - Throws: PeripheralManagerError
-    func wait(for characteristic: CBCharacteristic, timeout: TimeInterval) throws -> Data {
-        try runCommand(timeout: timeout) {
-            addCondition(.valueUpdate(characteristic: characteristic, matching: nil))
-        }
-
-        guard let value = characteristic.value else {
-            throw PeripheralManagerError.timeout
-        }
-
-        return value
-    }
 
     /// - Throws: PeripheralManagerError
     func writeValue(_ value: Data, for characteristic: CBCharacteristic, type: CBCharacteristicWriteType, timeout: TimeInterval) throws {
@@ -374,11 +364,12 @@ extension PeripheralManager: CBPeripheralDelegate {
         }
 
         commandLock.unlock()
+        delegate?.peripheralManager(self, didUpdateNotificationStateFor: characteristic)
     }
 
     func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
         commandLock.lock()
-
+        
         if let index = commandConditions.firstIndex(where: { (condition) -> Bool in
             if case .write(characteristic: characteristic) = condition {
                 return true
@@ -399,7 +390,7 @@ extension PeripheralManager: CBPeripheralDelegate {
 
     func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
         commandLock.lock()
-
+        
         var notifyDelegate = false
 
         if let index = commandConditions.firstIndex(where: { (condition) -> Bool in
@@ -417,7 +408,7 @@ extension PeripheralManager: CBPeripheralDelegate {
             }
         } else if let macro = configuration.valueUpdateMacros[characteristic.uuid] {
             macro(self)
-        } else if commandConditions.isEmpty {
+        } else {
             notifyDelegate = true // execute after the unlock
         }
 
@@ -459,12 +450,12 @@ extension PeripheralManager: CBCentralManagerDelegate {
     }
 }
 
-
 extension PeripheralManager {
+    
     public override var debugDescription: String {
         var items = [
             "## PeripheralManager",
-            "peripheral: \(peripheral)",
+            "peripheral: \(peripheral)"
         ]
         queue.sync {
             items.append("needsConfiguration: \(needsConfiguration)")

+ 16 - 7
Dependencies/rileylink_ios/RileyLinkBLEKit/PeripheralManagerError.swift

@@ -8,11 +8,14 @@
 import CoreBluetooth
 
 
-public enum PeripheralManagerError: Error {
+enum PeripheralManagerError: Error {
     case cbPeripheralError(Error)
     case notReady
-    case timeout
-    case unknownCharacteristic
+    case busy
+    case timeout([PeripheralManager.CommandCondition])
+    case emptyValue
+    case unknownCharacteristic(CBUUID)
+    case unknownService(CBUUID)
 }
 
 
@@ -22,11 +25,17 @@ extension PeripheralManagerError: LocalizedError {
         case .cbPeripheralError(let error):
             return error.localizedDescription
         case .notReady:
-            return LocalizedString("RileyLink is not connected", comment: "Not ready error description")
+            return LocalizedString("RileyLink is not connected", comment: "PeripheralManagerError.notReady error description")
+        case .busy:
+            return LocalizedString("RileyLink is busy", comment: "PeripheralManagerError.busy error description")
         case .timeout:
-            return LocalizedString("RileyLink did not respond in time", comment: "Timeout error description")
-        case .unknownCharacteristic:
-            return LocalizedString("Unknown characteristic", comment: "Error description")
+            return LocalizedString("RileyLink did not respond in time", comment: "PeripheralManagerError.timeout error description")
+        case .emptyValue:
+            return LocalizedString("Characteristic value was empty", comment: "PeripheralManagerError.emptyValue error description")
+        case .unknownCharacteristic(let cbuuid):
+            return String(format: LocalizedString("Unknown characteristic: %@", comment: "PeripheralManagerError.unknownCharacteristic error description"), cbuuid.uuidString)
+        case .unknownService(let cbuuid):
+            return String(format: LocalizedString("Unknown service: %@", comment: "PeripheralManagerError.unknownCharacteristic error description"), cbuuid.uuidString)
         }
     }
 

+ 2 - 2
Dependencies/rileylink_ios/RileyLinkBLEKit/RileyLinkConnectionManager.swift

@@ -8,7 +8,7 @@
 
 import Foundation
 
-public protocol RileyLinkConnectionManagerDelegate : class {
+public protocol RileyLinkConnectionManagerDelegate : AnyObject {
     func rileyLinkConnectionManager(_ rileyLinkConnectionManager: RileyLinkConnectionManager, didChange state: RileyLinkConnectionManagerState)
 }
 
@@ -85,7 +85,7 @@ public class RileyLinkConnectionManager {
     }
 }
 
-public protocol RileyLinkDeviceProvider: class {
+public protocol RileyLinkDeviceProvider: AnyObject {
     func getDevices(_ completion: @escaping (_ devices: [RileyLinkDevice]) -> Void)
     var idleListeningEnabled: Bool { get }
     var timerTickEnabled: Bool { get set }

+ 228 - 19
Dependencies/rileylink_ios/RileyLinkBLEKit/RileyLinkDevice.swift

@@ -8,6 +8,18 @@
 import CoreBluetooth
 import os.log
 
+public enum RileyLinkHardwareType {
+    case riley
+    case orange
+    case ema
+
+    var monitorsBattery: Bool {
+        if self == .riley {
+            return false
+        }
+        return true
+    }
+}
 
 /// TODO: Should we be tracking the most recent "pump" RSSI?
 public class RileyLinkDevice {
@@ -21,6 +33,27 @@ public class RileyLinkDevice {
     // Confined to `manager.queue`
     private var radioFirmwareVersion: RadioFirmwareVersion?
 
+    public var rlFirmwareDescription: String {
+        let versions = [radioFirmwareVersion, bleFirmwareVersion].compactMap { (version: CustomStringConvertible?) -> String? in
+            if let version = version {
+                return String(describing: version)
+            } else {
+                return nil
+            }
+        }
+
+        return versions.joined(separator: " / ")
+    }
+
+    private var version: String {
+        switch hardwareType {
+        case .riley, .ema, .none:
+            return rlFirmwareDescription
+        case .orange:
+            return orangeLinkFirmwareHardwareVersion
+        }
+    }
+
     // Confined to `lock`
     private var idleListeningState: IdleListeningState = .disabled
 
@@ -37,6 +70,45 @@ public class RileyLinkDevice {
     /// Serializes access to device state
     private var lock = os_unfair_lock()
 
+    private var orangeLinkFirmwareHardwareVersion = "v1.x"
+    private var orangeLinkHardwareVersionMajorMinor: [Int]?
+    private var ledOn: Bool = false
+    private var vibrationOn: Bool = false
+    private var voltage: Float?
+    private var batteryLevel: Int?
+    private var hasPiezo: Bool {
+        if let olHW = orangeLinkHardwareVersionMajorMinor, olHW[0] == 1, olHW[1] >= 1 {
+            return true
+        } else if let olHW = orangeLinkHardwareVersionMajorMinor, olHW[0] == 2, olHW[1] == 6 {
+            return true
+       }
+        return false
+    }
+
+    public var hasOrangeLinkService: Bool {
+        return self.manager.peripheral.services?.itemWithUUID(RileyLinkServiceUUID.orange.cbUUID) != nil
+    }
+
+    public var hardwareType: RileyLinkHardwareType? {
+        guard let services = self.manager.peripheral.services else {
+            return nil
+        }
+
+        guard let bleComponents = self.bleFirmwareVersion else {
+            return nil
+        }
+
+        if services.itemWithUUID(RileyLinkServiceUUID.secureDFU.cbUUID) != nil {
+            return .orange
+        } else if bleComponents.components[0] == 3 {
+            // this returns true for riley with ema firmware, but that is OK
+            return .ema
+        } else {
+            // as long as riley ble remains at 2.x with ema at 3.x this will work
+            return .riley
+        }
+      }
+
     /// The queue used to serialize sessions and observe when they've drained
     private let sessionQueue: OperationQueue = {
         let queue = OperationQueue()
@@ -93,6 +165,63 @@ extension RileyLinkDevice {
         manager.setCustomName(name)
     }
     
+    public func updateBatteryLevel() {
+        manager.readBatteryLevel { value in
+            if let batteryLevel = value {
+                self.batteryLevel = batteryLevel
+                NotificationCenter.default.post(
+                    name: .DeviceBatteryLevelUpdated,
+                    object: self,
+                    userInfo: [RileyLinkDevice.batteryLevelKey: batteryLevel]
+                )
+                NotificationCenter.default.post(name: .DeviceStatusUpdated, object: self)
+            }
+        }
+    }
+
+    public func orangeAction(_ command: OrangeLinkCommand) {
+        log.debug("orangeAction: %@", "\(command)")
+        manager.orangeAction(command)
+    }
+
+    public func setOrangeConfig(_ config: OrangeLinkConfigurationSetting, isOn: Bool) {
+        log.debug("setOrangeConfig: %@, %@", "\(String(describing: config))", "\(isOn)")
+        manager.setOrangeConfig(config, isOn: isOn)
+    }
+
+    public func orangeWritePwd() {
+        log.debug("orangeWritePwd")
+        manager.orangeWritePwd()
+    }
+
+    public func orangeClose() {
+        log.debug("orangeClose")
+        manager.orangeClose()
+    }
+
+    public func orangeReadSet() {
+        log.debug("orangeReadSet")
+        manager.orangeReadSet()
+    }
+
+    public func orangeReadVDC() {
+        log.debug("orangeReadVDC")
+        manager.orangeReadVDC()
+    }
+
+    public func findDevice() {
+        log.debug("findDevice")
+        manager.findDevice()
+    }
+
+    public func setDiagnosticeLEDModeForBLEChip(_ mode: RileyLinkLEDMode) {
+        manager.setLEDMode(mode: mode)
+    }
+
+    public func readDiagnosticLEDModeForBLEChip(completion: @escaping (RileyLinkLEDMode?) -> Void) {
+        manager.readDiagnosticLEDMode(completion: completion)
+    }
+
     public func enableBLELEDs() {
         manager.setLEDMode(mode: .on)
     }
@@ -131,9 +260,13 @@ extension RileyLinkDevice {
 
         public let name: String?
 
-        public let bleFirmwareVersion: BLEFirmwareVersion?
+        public let version: String
 
-        public let radioFirmwareVersion: RadioFirmwareVersion?
+        public let ledOn: Bool
+        public let vibrationOn: Bool
+        public let voltage: Float?
+        public let battery: Int?
+        public let hasPiezo: Bool
     }
 
     public func getStatus(_ completion: @escaping (_ status: Status) -> Void) {
@@ -145,8 +278,12 @@ extension RileyLinkDevice {
             completion(Status(
                 lastIdle: lastIdle,
                 name: self.name,
-                bleFirmwareVersion: self.bleFirmwareVersion,
-                radioFirmwareVersion: self.radioFirmwareVersion
+                version: self.version,
+                ledOn: self.ledOn,
+                vibrationOn: self.vibrationOn,
+                voltage: self.voltage,
+                battery: self.batteryLevel,
+                hasPiezo: self.hasPiezo
             ))
         }
     }
@@ -154,8 +291,12 @@ extension RileyLinkDevice {
 
 
 // MARK: - Command session management
+// CommandSessions are a way to serialize access to the RileyLink command/response facility.
+// All commands that send data out on the RL data characteristic need to be in a command session.
+// Accessing other characteristics on the RileyLink can be done without a command session.
 extension RileyLinkDevice {
     public func runSession(withName name: String, _ block: @escaping (_ session: CommandSession) -> Void) {
+        self.log.default("Scheduling session %{public}@", name)
         sessionQueue.addOperation(manager.configureAndRun({ [weak self] (manager) in
             self?.log.default("======================== %{public}@ ===========================", name)
             let bleFirmwareVersion = self?.bleFirmwareVersion
@@ -219,7 +360,6 @@ extension RileyLinkDevice {
 
         self.isIdleListeningPending = true
         os_unfair_lock_unlock(&lock)
-        self.log.debug("Enqueuing idle listening")
 
         self.manager.startIdleListening(idleTimeout: timeout, channel: channel) { (error) in
             os_unfair_lock_lock(&self.lock)
@@ -230,6 +370,7 @@ extension RileyLinkDevice {
                 os_unfair_lock_unlock(&self.lock)
             } else {
                 self.lastIdle = Date()
+                self.log.debug("Started idle listening")
                 os_unfair_lock_unlock(&self.lock)
                 NotificationCenter.default.post(name: .DeviceDidStartIdle, object: self)
             }
@@ -271,7 +412,7 @@ extension RileyLinkDevice {
     }
 
     func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
-        log.debug("didConnect %@", peripheral)
+        log.default("didConnect %{public}@", peripheral)
         if case .connected = peripheral.state {
             assertIdleListening(forceRestart: false)
             assertTimerTick()
@@ -283,24 +424,55 @@ extension RileyLinkDevice {
     }
 
     func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
-        log.debug("didDisconnectPeripheral %@", peripheral)
+        log.default("didDisconnectPeripheral %{public}@", peripheral)
         NotificationCenter.default.post(name: .DeviceConnectionStateDidChange, object: self)
     }
 
     func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
-        log.debug("didFailToConnect %@", peripheral)
+        log.default("didFailToConnect %{public}@", peripheral)
         NotificationCenter.default.post(name: .DeviceConnectionStateDidChange, object: self)
     }
 }
 
 
 extension RileyLinkDevice: PeripheralManagerDelegate {
-    // This is called from the central's queue
+    func peripheralManager(_ manager: PeripheralManager, didUpdateNotificationStateFor characteristic: CBCharacteristic) {
+        log.debug("Did didUpdateNotificationStateFor %@", characteristic)
+    }
+
+    // If PeripheralManager receives a response on the data queue, without an outstanding request,
+    // it will pass the update to this method, which is called on the central's queue.
+    // This is how idle listen responses are handled
     func peripheralManager(_ manager: PeripheralManager, didUpdateValueFor characteristic: CBCharacteristic) {
-        log.debug("Did UpdateValueFor %@", characteristic)
-        switch MainServiceCharacteristicUUID(rawValue: characteristic.uuid.uuidString) {
-        case .data?:
-            guard let value = characteristic.value, value.count > 0 else {
+        let characteristicService: CBService? = characteristic.service
+        guard let cbService = characteristicService, let service = RileyLinkServiceUUID(rawValue: cbService.uuid.uuidString) else {
+            log.debug("Update from characteristic on unknown service: %@", String(describing: characteristic.service))
+            return
+        }
+
+        switch service {
+        case .main:
+            guard let mainCharacteristic = MainServiceCharacteristicUUID(rawValue: characteristic.uuid.uuidString) else {
+                log.debug("Update from unknown characteristic %@ on main service.", characteristic.uuid.uuidString)
+                return
+            }
+            handleCharacteristicUpdate(mainCharacteristic, value: characteristic.value)
+
+        case .orange:
+            guard let orangeCharacteristic = OrangeServiceCharacteristicUUID(rawValue: characteristic.uuid.uuidString) else {
+                log.debug("Update from unknown characteristic %@ on orange service.", characteristic.uuid.uuidString)
+                return
+            }
+            handleCharacteristicUpdate(orangeCharacteristic, value: characteristic.value)
+        default:
+            return
+        }
+    }
+
+    private func handleCharacteristicUpdate(_ characteristic: MainServiceCharacteristicUUID, value: Data?) {
+        switch characteristic {
+        case .data:
+            guard let value = value, value.count > 0 else {
                 return
             }
 
@@ -319,7 +491,10 @@ extension RileyLinkDevice: PeripheralManagerDelegate {
 
                     if let response = response {
                         switch response.code {
-                        case .rxTimeout, .commandInterrupted, .zeroData, .invalidParam, .unknownCommand:
+                        case .commandInterrupted:
+                            self.log.debug("Received commandInterrupted during idle; assuming device is still listening.")
+                            return
+                        case .rxTimeout, .zeroData, .invalidParam, .unknownCommand:
                             self.log.debug("Idle error received: %@", String(describing: response.code))
                         case .success:
                             if let packet = response.packet {
@@ -337,21 +512,47 @@ extension RileyLinkDevice: PeripheralManagerDelegate {
                 } else {
                     self.log.error("Skipping parsing characteristic value update due to missing BLE firmware version")
                 }
-
                 self.assertIdleListening(forceRestart: true)
             }
-        case .responseCount?:
+        case .responseCount:
             // PeripheralManager.Configuration.valueUpdateMacros is responsible for handling this response.
             break
-        case .timerTick?:
+        case .timerTick:
             NotificationCenter.default.post(name: .DeviceTimerDidTick, object: self)
-
             assertIdleListening(forceRestart: false)
-        case .customName?, .firmwareVersion?, .ledMode?, .none:
+        case .customName, .firmwareVersion, .ledMode:
             break
         }
     }
 
+    private func handleCharacteristicUpdate(_ characteristic: OrangeServiceCharacteristicUUID, value: Data?) {
+        switch characteristic {
+        case .orangeRX, .orangeTX:
+            guard let data = value, !data.isEmpty else { return }
+            if data.first == 0xbb {
+                guard data.count > 6 else { return }
+                if data[1] == 0x09, data[2] == 0xaa {
+                    orangeLinkFirmwareHardwareVersion = "FW\(data[3]).\(data[4])/HW\(data[5]).\(data[6])"
+                    orangeLinkHardwareVersionMajorMinor = [Int(data[5]), Int(data[6])]
+                    NotificationCenter.default.post(name: .DeviceStatusUpdated, object: self)
+                }
+            } else if data.first == OrangeLinkRequestType.cfgHeader.rawValue {
+                guard data.count > 2 else { return }
+                if data[1] == 0x01 {
+                    guard data.count > 5 else { return }
+                    ledOn = (data[3] != 0)
+                    vibrationOn = (data[4] != 0)
+                    NotificationCenter.default.post(name: .DeviceStatusUpdated, object: self)
+                } else if data[1] == 0x03 {
+                    guard data.count > 4 else { return }
+                    let int = UInt16(bigEndian: Data(data[3...4]).withUnsafeBytes { $0.load(as: UInt16.self) })
+                    voltage = Float(int) / 1000
+                    NotificationCenter.default.post(name: .DeviceStatusUpdated, object: self)
+                }
+            }
+        }
+    }
+
     func peripheralManager(_ manager: PeripheralManager, didReadRSSI RSSI: NSNumber, error: Error?) {
         NotificationCenter.default.post(
             name: .DeviceRSSIDidChange,
@@ -376,6 +577,8 @@ extension RileyLinkDevice: PeripheralManagerDelegate {
 
         let radioVersionString = try manager.readRadioFirmwareVersion(timeout: 1, responseType: bleFirmwareVersion?.responseType ?? .buffered)
         radioFirmwareVersion = RadioFirmwareVersion(versionString: radioVersionString)
+
+        try manager.setOrangeNotifyOn()
     }
 }
 
@@ -408,6 +611,8 @@ extension RileyLinkDevice {
     public static let notificationPacketKey = "com.rileylink.RileyLinkBLEKit.RileyLinkDevice.NotificationPacket"
 
     public static let notificationRSSIKey = "com.rileylink.RileyLinkBLEKit.RileyLinkDevice.NotificationRSSI"
+
+    public static let batteryLevelKey = "com.rileylink.RileyLinkBLEKit.RileyLinkDevice.BatteryLevel"
 }
 
 
@@ -423,4 +628,8 @@ extension Notification.Name {
     public static let DeviceRSSIDidChange = Notification.Name(rawValue: "com.rileylink.RileyLinkBLEKit.RSSIDidChange")
 
     public static let DeviceTimerDidTick = Notification.Name(rawValue: "com.rileylink.RileyLinkBLEKit.TimerTickDidChange")
+
+    public static let DeviceStatusUpdated = Notification.Name(rawValue: "com.rileylink.RileyLinkBLEKit.DeviceStatusUpdated")
+
+    public static let DeviceBatteryLevelUpdated = Notification.Name(rawValue: "com.rileylink.RileyLinkBLEKit.BatteryLevelUpdated")
 }

+ 6 - 1
Dependencies/rileylink_ios/RileyLinkBLEKit/RileyLinkDeviceError.swift

@@ -7,11 +7,12 @@
 
 
 public enum RileyLinkDeviceError: Error {
-    case peripheralManagerError(PeripheralManagerError)
+    case peripheralManagerError(LocalizedError)
     case invalidInput(String)
     case writeSizeLimitExceeded(maxLength: Int)
     case invalidResponse(Data)
     case responseTimeout
+    case commandsBlocked
     case unsupportedCommand(String)
 }
 
@@ -29,6 +30,8 @@ extension RileyLinkDeviceError: LocalizedError {
             return String(format: LocalizedString("Data exceeded maximum size of %@ bytes", comment: "Write size limit exceeded error description (1: size limit)"), NumberFormatter.localizedString(from: NSNumber(value: maxLength), number: .none))
         case .responseTimeout:
             return LocalizedString("Pump did not respond in time", comment: "Response timeout error description")
+        case .commandsBlocked:
+            return LocalizedString("RileyLink command did not respond", comment: "commandsBlocked error description")
         case .unsupportedCommand(let command):
             return String(format: LocalizedString("RileyLink firmware does not support the %@ command", comment: "Unsupported command error description"), String(describing: command))
         }
@@ -47,6 +50,8 @@ extension RileyLinkDeviceError: LocalizedError {
         switch self {
         case .peripheralManagerError(let error):
             return error.recoverySuggestion
+        case .commandsBlocked:
+            return LocalizedString("RileyLink may need to be turned off and back on", comment: "commandsBlocked recovery suggestion")
         default:
             return nil
         }

+ 3 - 7
Dependencies/rileylink_ios/RileyLinkKit/PumpOpsSession.swift

@@ -11,7 +11,7 @@ import LoopKit
 import RileyLinkBLEKit
 
 
-protocol PumpOpsSessionDelegate: class {
+protocol PumpOpsSessionDelegate: AnyObject {
     func pumpOpsSession(_ session: PumpOpsSession, didChange state: PumpState)
     func pumpOpsSessionDidChangeRadioConfig(_ session: PumpOpsSession)
 }
@@ -827,12 +827,8 @@ extension PumpOpsSession {
     }
 
     /// - Throws: PumpOpsError.deviceError
-    public func enableCCLEDs() throws {
-        do {
-            try session.enableCCLEDs()
-        } catch let error as LocalizedError {
-            throw PumpOpsError.deviceError(error)
-        }
+    func setCCLEDMode(_ mode: RileyLinkLEDMode) throws {
+        throw PumpOpsError.noResponse(during: "Tests")
     }
 
     /// - Throws:

+ 0 - 16
Dependencies/rileylink_ios/RileyLinkKit/RileyLinkDevice.swift

@@ -7,22 +7,6 @@
 
 import RileyLinkBLEKit
 
-
-extension RileyLinkDevice.Status {
-    public var firmwareDescription: String {
-        let versions = [radioFirmwareVersion, bleFirmwareVersion].compactMap { (version: CustomStringConvertible?) -> String? in
-            if let version = version {
-                return String(describing: version)
-            } else {
-                return nil
-            }
-        }
-
-        return versions.joined(separator: " / ")
-    }
-}
-
-
 extension Notification.Name {
     public static let DeviceRadioConfigDidChange = Notification.Name(rawValue: "com.rileylink.RileyLinkKit.DeviceRadioConfigDidChange")
 

+ 2 - 0
Dependencies/rileylink_ios/RileyLinkKit/RileyLinkPumpManager.swift

@@ -43,6 +43,8 @@ open class RileyLinkPumpManager {
 
     open func deviceTimerDidTick(_ device: RileyLinkDevice) { }
 
+    open func device(_ device: RileyLinkDevice, didUpdateBattery level: Int) { }
+
     // MARK: - CustomDebugStringConvertible
     
     open var debugDescription: String {

+ 63 - 0
Dependencies/rileylink_ios/RileyLinkKitUI/Base.lproj/Localizable.strings

@@ -37,5 +37,68 @@
 /* The title of the cell showing BLE signal strength (RSSI) */
 "Signal Strength" = "Signal Strength";
 
+/* The header of the cells showing connection monitoring */
+"Connection Monitoring" = "Connection Monitoring";
+
 /* The title of the cell showing uptime */
 "Uptime" = "Uptime";
+
+/* The title of the cell showing battery level */
+"Battery level" = "Battery level";
+
+/* The title of the cell showing Voltage */
+"Voltage" = "Voltage";
+
+/* "The title of the section for alerts" */
+"Alert" = "Alert";
+
+/* The title of the cell showing Low Battery Alert */
+"Low Battery Alert" = "Low Battery Alert";
+
+/* Header of list showing battery level alert options */
+"Battery level Alert" = "Battery level Alert";
+
+/* Battery level alert OFF in list of options */
+"OFF" = "OFF";
+
+/* The title of the command to update diagnostic LEDs */
+"Diagnostic LEDs" = "Diagnostic LEDs";
+
+/* The title of the command to fetch RileyLink statistics */
+"Get RileyLink Statistics" = "Get RileyLink Statistics";
+
+/* The title of the command to invert BLE connection LED logic */
+"Invert LED Logic" = "Invert LED Logic";
+
+/* The header of the cells showing test commands */
+"Test Commands" = "Test Commands";
+
+/* The title of the cell showing Lighten Yellow LED */
+"Lighten Yellow LED" = "Lighten Yellow LED";
+
+/* The title of the cell showing Lighten Yellow LED */
+"Lighten Red LED" = "Lighten Red LED";
+
+/* The title of the cell showing Test Vibration */
+"Test Vibration" = "Test Vibration";
+
+/* The title of the cell for sounding device finding piezo */
+"Find Device" = "Find Device";
+
+/* The title of the cell for connection LED */
+"Connection LED" = "Connection LED";
+
+/* The title of the cell for connection vibration */
+"Connection Vibration" = "Connection Vibration";
+
+/* Detail text when battery alert disabled. */
+"Off" = "Off";
+
+/* Text indicating LED Mode is on */
+"On" = "On";
+
+/* Text indicating LED Mode is off */
+"Off" = "Off";
+
+/* Text indicating LED Mode is auto */
+"Auto" = "Auto";

+ 62 - 0
Dependencies/rileylink_ios/RileyLinkKitUI/CommandResponseViewController.swift

@@ -0,0 +1,62 @@
+//
+//  CommandResponseViewController.swift
+//  RileyLinkKitUI
+//
+//  Created by Pete Schwamb on 7/19/21.
+//  Copyright © 2021 Pete Schwamb. All rights reserved.
+//
+
+import Foundation
+import LoopKitUI
+import RileyLinkBLEKit
+
+extension CommandResponseViewController {
+    typealias T = CommandResponseViewController
+    
+    static func getStatistics(device: RileyLinkDevice) -> T {
+        return T { (completionHandler) -> String in
+            device.runSession(withName: "Get Statistics") { session in
+                let response: String
+
+                do {
+                    let stats = try session.getRileyLinkStatistics()
+                    response = String(describing: stats)
+                } catch let error {
+                    response = String(describing: error)
+                }
+
+                DispatchQueue.main.async {
+                    completionHandler(response)
+                }
+            }
+            
+            return LocalizedString("Get Statistics…", comment: "Progress message for getting statistics.")
+        }
+    }
+    
+    static func setDiagnosticLEDMode(device: RileyLinkDevice, mode: RileyLinkLEDMode) -> T {
+        return T { (completionHandler) -> String in
+            device.setDiagnosticeLEDModeForBLEChip(mode)
+            device.runSession(withName: "Update diagnostic LED mode") { session in
+                let response: String
+                do {
+                    try session.setCCLEDMode(mode)
+                    switch mode {
+                    case .on:
+                        response = "Diagnostic mode enabled"
+                    default:
+                        response = "Diagnostic mode disabled"
+                    }
+                } catch let error {
+                    response = String(describing: error)
+                }
+
+                DispatchQueue.main.async {
+                    completionHandler(response)
+                }
+            }
+
+            return LocalizedString("Updating diagnostic LEDs mode", comment: "Progress message for changing diagnostic LED mode")
+        }
+    }
+}

+ 555 - 70
Dependencies/rileylink_ios/RileyLinkKitUI/RileyLinkDeviceTableViewController.swift

@@ -14,6 +14,30 @@ import os.log
 
 let CellIdentifier = "Cell"
 
+public class RileyLinkSwitch: UISwitch {
+    
+    public var index: Int = 0
+    public var section: Int = 0
+}
+
+public class RileyLinkCell: UITableViewCell {
+    public let switchView = RileyLinkSwitch()
+    
+    public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
+        super.init(style: style, reuseIdentifier: reuseIdentifier)
+        contentView.addSubview(switchView)
+    }
+    
+    public required init?(coder aDecoder: NSCoder) {
+        super.init(coder: aDecoder)
+    }
+    
+    public override func layoutSubviews() {
+        super.layoutSubviews()
+        switchView.frame = CGRect(x: frame.width - 51 - 20, y: (frame.height - 31) / 2, width: 51, height: 31)
+    }
+}
+
 public class RileyLinkDeviceTableViewController: UITableViewController {
 
     private let log = OSLog(category: "RileyLinkDeviceTableViewController")
@@ -42,6 +66,15 @@ public class RileyLinkDeviceTableViewController: UITableViewController {
         }
     }
     
+    private var battery: Int? {
+        didSet {
+            guard isViewLoaded else {
+                return
+            }
+            cellForRow(.battery)?.setDetailBatteryLevel(battery)
+        }
+    }
+    
     private var frequency: Measurement<UnitFrequency>? {
         didSet {
             guard isViewLoaded else {
@@ -51,6 +84,15 @@ public class RileyLinkDeviceTableViewController: UITableViewController {
             cellForRow(.frequency)?.setDetailFrequency(frequency, formatter: frequencyFormatter)
         }
     }
+    
+    private var ledMode: RileyLinkLEDMode? {
+        didSet {
+            guard isViewLoaded else {
+                return
+            }
+            cellForRow(.diagnosticLEDSMode)?.setLEDMode(ledMode)
+        }
+    }
 
     var rssiFetchTimer: Timer? {
         willSet {
@@ -58,23 +100,31 @@ public class RileyLinkDeviceTableViewController: UITableViewController {
         }
     }
     
-    private lazy var frequencyFormatter: MeasurementFormatter = {
-        let formatter = MeasurementFormatter()
-        
-        formatter.numberFormatter = decimalFormatter
-        
-        return formatter
-    }()
-
-
+    private var hasPiezo: Bool = false
+    
     private var appeared = false
+    
+    private var batteryAlertLevel: Int? {
+        didSet {
+            batteryAlertLevelChanged?(batteryAlertLevel)
+        }
+    }
+    
+    private var batteryAlertLevelChanged: ((Int?) -> Void)?
 
-    public init(device: RileyLinkDevice) {
+    public init(device: RileyLinkDevice, batteryAlertLevel: Int?, batteryAlertLevelChanged: ((Int?) -> Void)? ) {
         self.device = device
+        self.batteryAlertLevel = batteryAlertLevel
+        self.batteryAlertLevelChanged = batteryAlertLevelChanged
 
         super.init(style: .grouped)
 
         updateDeviceStatus()
+        
+        NotificationCenter.default.addObserver(forName: .DeviceStatusUpdated, object: device, queue: .main)
+        { (notification) in
+            self.updateDeviceStatus()
+        }
     }
 
     required public init?(coder aDecoder: NSCoder) {
@@ -85,7 +135,63 @@ public class RileyLinkDeviceTableViewController: UITableViewController {
         super.viewDidLoad()
 
         title = device.name
-
+        
+        switch device.hardwareType {
+        case .riley, .none:
+            deviceRows = [
+                .customName,
+                .version,
+                .rssi,
+                .connection,
+                .uptime,
+                .frequency
+            ]
+            
+            sections = [
+                .device,
+                .rileyLinkCommands
+            ]
+        case .ema:
+            deviceRows = [
+                .customName,
+                .version,
+                .rssi,
+                .connection,
+                .uptime,
+                .frequency,
+                .battery
+            ]
+
+            sections = [
+                .device,
+                .alert,
+                .emaLinkCommands
+            ]
+        case .orange:
+            deviceRows = [
+                .customName,
+                .version,
+                .rssi,
+                .connection,
+                .uptime,
+                .battery,
+                .voltage
+            ]
+            
+            if device.hasOrangeLinkService {
+                sections = [
+                    .device,
+                    .alert,
+                    .configureCommand,
+                    .orangeLinkCommands
+                ]
+            } else {
+                sections = [
+                    .device
+                ]
+            }
+        }
+        
         self.observe()
     }
     
@@ -93,10 +199,17 @@ public class RileyLinkDeviceTableViewController: UITableViewController {
         device.readRSSI()
     }
 
+    // This does not trigger any BLE reads; it just gets status from the device in a safe manner, and reloads the table
     func updateDeviceStatus() {
         device.getStatus { (status) in
             DispatchQueue.main.async {
-                self.firmwareVersion = status.firmwareDescription
+                self.firmwareVersion = status.version
+                self.ledOn = status.ledOn
+                self.vibrationOn = status.vibrationOn
+                self.voltage = status.voltage
+                self.battery = status.battery
+                self.hasPiezo = status.hasPiezo
+                self.tableView.reloadData()
             }
         }
     }
@@ -126,7 +239,14 @@ public class RileyLinkDeviceTableViewController: UITableViewController {
                 self.log.error("Failed to get base frequency: %{public}@", String(describing: error))
             }
         }
-        
+    }
+    
+    func readDiagnosticLEDMode() {
+        device.readDiagnosticLEDModeForBLEChip(completion: { ledMode in
+            DispatchQueue.main.async {
+                self.ledMode = ledMode
+            }
+        })
     }
 
     // References to registered notification center observers
@@ -144,27 +264,30 @@ public class RileyLinkDeviceTableViewController: UITableViewController {
         
         notificationObservers = [
             center.addObserver(forName: .DeviceNameDidChange, object: device, queue: mainQueue) { [weak self] (note) -> Void in
-                if let cell = self?.cellForRow(.customName) {
-                    cell.detailTextLabel?.text = self?.device.name
-                }
-
-                self?.title = self?.device.name
-            },
+            if let cell = self?.cellForRow(.customName) {
+                cell.detailTextLabel?.text = self?.device.name
+            }
+            self?.title = self?.device.name
+            self?.tableView.reloadData()
+        },
             center.addObserver(forName: .DeviceConnectionStateDidChange, object: device, queue: mainQueue) { [weak self] (note) -> Void in
-                if let cell = self?.cellForRow(.connection) {
-                    cell.detailTextLabel?.text = self?.device.peripheralState.description
-                }
-            },
+            if let cell = self?.cellForRow(.connection) {
+                cell.detailTextLabel?.text = self?.device.peripheralState.description
+            }
+        },
             center.addObserver(forName: .DeviceRSSIDidChange, object: device, queue: mainQueue) { [weak self] (note) -> Void in
-                self?.bleRSSI = note.userInfo?[RileyLinkDevice.notificationRSSIKey] as? Int
-
-                if let cell = self?.cellForRow(.rssi), let formatter = self?.integerFormatter {
-                    cell.setDetailRSSI(self?.bleRSSI, formatter: formatter)
-                }
-            },
+            self?.bleRSSI = note.userInfo?[RileyLinkDevice.notificationRSSIKey] as? Int
+            
+            if let cell = self?.cellForRow(.rssi), let formatter = self?.integerFormatter {
+                cell.setDetailRSSI(self?.bleRSSI, formatter: formatter)
+            }
+        },
             center.addObserver(forName: .DeviceDidStartIdle, object: device, queue: mainQueue) { [weak self] (note) in
-                self?.updateDeviceStatus()
-            },
+            self?.updateDeviceStatus()
+        },
+            center.addObserver(forName: .DeviceStatusUpdated, object: device, queue: mainQueue) { [weak self] (note) in
+            self?.updateDeviceStatus()
+        },
         ]
     }
     
@@ -181,10 +304,38 @@ public class RileyLinkDeviceTableViewController: UITableViewController {
         
         updateRSSI()
         
-        updateFrequency()
+        if deviceRows.contains(.frequency) {
+            updateFrequency()
+        }
 
         updateUptime()
         
+        switch device.hardwareType {
+        case .riley:
+            readDiagnosticLEDMode()
+        case .ema:
+            device.updateBatteryLevel()
+            readDiagnosticLEDMode()
+        case .orange:
+            device.updateBatteryLevel()
+            device.orangeWritePwd()
+            device.orangeReadSet()
+            device.orangeReadVDC()
+            device.orangeAction(.fw_hw)
+        default:
+            break
+        }
+    }
+    
+    public override func viewDidDisappear(_ animated: Bool) {
+        super.viewDidDisappear(animated)
+        if redOn || yellowOn {
+            device.orangeAction(.off)
+        }
+        
+        if shakeOn {
+            device.orangeAction(.shakeOff)
+        }
     }
     
     public override func viewWillDisappear(_ animated: Bool) {
@@ -206,70 +357,213 @@ public class RileyLinkDeviceTableViewController: UITableViewController {
     
     private lazy var integerFormatter = NumberFormatter()
 
-    private lazy var measurementFormatter: MeasurementFormatter = {
-        let formatter = MeasurementFormatter()
-
-        formatter.numberFormatter = decimalFormatter
-
-        return formatter
-    }()
-
     private lazy var decimalFormatter: NumberFormatter = {
         let decimalFormatter = NumberFormatter()
 
         decimalFormatter.numberStyle = .decimal
-        decimalFormatter.minimumSignificantDigits = 5
-
+        decimalFormatter.maximumFractionDigits = 2
         return decimalFormatter
     }()
+    
+    private lazy var frequencyFormatter: MeasurementFormatter = {
+        let formatter = MeasurementFormatter()
+        formatter.numberFormatter = decimalFormatter
+        return formatter
+    }()
 
     // MARK: - Table view data source
 
-    private enum Section: Int, CaseCountable {
+    private enum Section: Int, CaseIterable {
         case device
-        case commands
+        case alert
+        case configureCommand
+        case orangeLinkCommands
+        case rileyLinkCommands
+        case emaLinkCommands
     }
+    
+    private var sections: [Section] = []
 
-    private enum DeviceRow: Int, CaseCountable {
+    private enum AlertRow: Int, CaseIterable {
+        case battery
+    }
+
+    private enum DeviceRow: Int, CaseIterable {
         case customName
         case version
         case rssi
         case connection
         case uptime
         case frequency
+        case battery
+        case voltage
+    }
+    
+    private var deviceRows: [DeviceRow] = []
+    
+    private enum RileyLinkCommandRow: Int, CaseIterable {
+        case diagnosticLEDSMode
+        case getStatistics
+    }
+
+    private enum EmaLinkCommandRow: Int, CaseIterable {
+        case logicLEDSMode
+        case getStatistics
+    }
+
+    private enum OrangeLinkCommandRow: Int, CaseIterable {
+        case yellow
+        case red
+        case shake
+        case findDevice
+    }
+
+    private enum OrangeConfigureCommandRow: Int, CaseIterable {
+        case connectionLED
+        case connectionVibrate
     }
 
     private func cellForRow(_ row: DeviceRow) -> UITableViewCell? {
-        return tableView.cellForRow(at: IndexPath(row: row.rawValue, section: Section.device.rawValue))
+        guard let rowIndex = deviceRows.firstIndex(of: row),
+              let sectionIndex = sections.firstIndex(of: Section.device) else
+        {
+            return nil
+        }
+        return tableView.cellForRow(at: IndexPath(row: rowIndex, section: sectionIndex))
+    }
+
+    private func cellForRow(_ row: OrangeConfigureCommandRow) -> UITableViewCell? {
+        guard let sectionIndex = sections.firstIndex(of: Section.orangeLinkCommands) else
+        {
+            return nil
+        }
+        return tableView.cellForRow(at: IndexPath(row: row.rawValue, section: sectionIndex))
+    }
+
+    private func cellForRow(_ row: OrangeLinkCommandRow) -> UITableViewCell? {
+        guard let sectionIndex = sections.firstIndex(of: Section.orangeLinkCommands) else
+        {
+            return nil
+        }
+        return tableView.cellForRow(at: IndexPath(row: row.rawValue, section: sectionIndex))
+    }
+    
+    private func cellForRow(_ row: RileyLinkCommandRow) -> UITableViewCell? {
+        guard let sectionIndex = sections.firstIndex(of: Section.rileyLinkCommands) else
+        {
+            return nil
+        }
+        return tableView.cellForRow(at: IndexPath(row: row.rawValue, section: sectionIndex))
+    }
+
+    private func cellForRow(_ row: EmaLinkCommandRow) -> UITableViewCell? {
+        guard let sectionIndex = sections.firstIndex(of: Section.emaLinkCommands) else
+        {
+            return nil
+        }
+        return tableView.cellForRow(at: IndexPath(row: row.rawValue, section: sectionIndex))
     }
 
     public override func numberOfSections(in tableView: UITableView) -> Int {
-        return Section.count
+        return sections.count
     }
 
     public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
-        switch Section(rawValue: section)! {
-        case .device:
-            return DeviceRow.count
-        case .commands:
+        guard section < sections.count else {
             return 0
         }
+        
+        switch sections[section] {
+        case .device:
+            return deviceRows.count
+        case .rileyLinkCommands:
+            return RileyLinkCommandRow.allCases.count
+        case .emaLinkCommands:
+            return EmaLinkCommandRow.allCases.count
+        case .configureCommand:
+            return OrangeConfigureCommandRow.allCases.count
+        case .orangeLinkCommands:
+            let count = OrangeLinkCommandRow.allCases.count
+            return hasPiezo ? count : count-1
+        case .alert:
+            return AlertRow.allCases.count
+        }
     }
+    
+    @objc
+    func switchAction(sender: RileyLinkSwitch) {
+        switch sections[sender.section] {
+        case .orangeLinkCommands:
+            switch OrangeLinkCommandRow(rawValue: sender.index)! {
+            case .yellow:
+                if sender.isOn {
+                    device.orangeAction(.yellow)
+                } else {
+                    device.orangeAction(.off)
+                }
+                yellowOn = sender.isOn
+                redOn = false
+            case .red:
+                if sender.isOn {
+                    device.orangeAction(.red)
+                } else {
+                    device.orangeAction(.off)
+                }
+                yellowOn = false
+                redOn = sender.isOn
+            case .shake:
+                if sender.isOn {
+                    device.orangeAction(.shake)
+                } else {
+                    device.orangeAction(.shakeOff)
+                }
+                shakeOn = sender.isOn
+            default:
+                break
+            }
+        case .configureCommand:
+            switch OrangeConfigureCommandRow(rawValue: sender.index)! {
+            case .connectionLED:
+                device.setOrangeConfig(.connectionLED, isOn: sender.isOn)
+                ledOn = sender.isOn
+            case .connectionVibrate:
+                device.setOrangeConfig(.connectionVibrate, isOn: sender.isOn)
+                vibrationOn = sender.isOn
+            }
+        default:
+            break
+        }
+        tableView.reloadData()
+    }
+    
+    var yellowOn = false
+    var redOn = false
+    var shakeOn = false
+    private var ledOn: Bool = false
+    private var vibrationOn: Bool = false
+    var voltage: Float?
 
     public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
-        let cell: UITableViewCell
+        let cell: RileyLinkCell
 
-        if let reusableCell = tableView.dequeueReusableCell(withIdentifier: CellIdentifier) {
+        if let reusableCell = tableView.dequeueReusableCell(withIdentifier: CellIdentifier) as? RileyLinkCell {
             cell = reusableCell
         } else {
-            cell = UITableViewCell(style: .value1, reuseIdentifier: CellIdentifier)
+            cell = RileyLinkCell(style: .value1, reuseIdentifier: CellIdentifier)
+            cell.switchView.addTarget(self, action: #selector(switchAction(sender:)), for: .valueChanged)
         }
-
+        
+        let switchView = cell.switchView
+        switchView.isHidden = true
+        switchView.index = indexPath.row
+        switchView.section = indexPath.section
+        
         cell.accessoryType = .none
+        cell.detailTextLabel?.text = nil
 
-        switch Section(rawValue: indexPath.section)! {
+        switch sections[indexPath.section] {
         case .device:
-            switch DeviceRow(rawValue: indexPath.row)! {
+            switch deviceRows[indexPath.row] {
             case .customName:
                 cell.textLabel?.text = LocalizedString("Name", comment: "The title of the cell showing device name")
                 cell.detailTextLabel?.text = device.name
@@ -289,44 +583,128 @@ public class RileyLinkDeviceTableViewController: UITableViewController {
             case .frequency:
                 cell.textLabel?.text = LocalizedString("Frequency", comment: "The title of the cell showing current rileylink frequency")
                 cell.setDetailFrequency(frequency, formatter: frequencyFormatter)
+            case .battery:
+                cell.textLabel?.text = LocalizedString("Battery level", comment: "The title of the cell showing battery level")
+                cell.setDetailBatteryLevel(battery)
+            case .voltage:
+                cell.textLabel?.text = LocalizedString("Voltage", comment: "The title of the cell showing ORL")
+                cell.setVoltage(voltage)
+            }
+        case .alert:
+            switch AlertRow(rawValue: indexPath.row)! {
+            case .battery:
+                cell.accessoryType = .disclosureIndicator
+                cell.textLabel?.text = LocalizedString("Low Battery Alert", comment: "The title of the cell showing battery level")
+                cell.setBatteryAlert(batteryAlertLevel, formatter: integerFormatter)
             }
-        case .commands:
+        case .rileyLinkCommands:
+            switch RileyLinkCommandRow(rawValue: indexPath.row)! {
+            case .diagnosticLEDSMode:
+                cell.textLabel?.text = LocalizedString("Diagnostic LEDs", comment: "The title of the command to update diagnostic LEDs")
+                cell.setLEDMode(ledMode)
+            case .getStatistics:
+                cell.textLabel?.text = LocalizedString("Get RileyLink Statistics", comment: "The title of the command to fetch RileyLink statistics")
+            }
+        case .emaLinkCommands:
+            switch EmaLinkCommandRow(rawValue: indexPath.row)! {
+            case .logicLEDSMode:
+                cell.textLabel?.text = LocalizedString("Invert LED Logic", comment: "The title of the command to invert BLE connection LED logic")
+                cell.setLEDMode(ledMode)
+            case .getStatistics:
+                cell.textLabel?.text = LocalizedString("Get RileyLink Statistics", comment: "The title of the command to fetch RileyLink statistics")
+            }
+        case .orangeLinkCommands:
             cell.accessoryType = .disclosureIndicator
             cell.detailTextLabel?.text = nil
+            
+            switch OrangeLinkCommandRow(rawValue: indexPath.row)! {
+            case .yellow:
+                switchView.isHidden = false
+                cell.accessoryType = .none
+                switchView.isOn = yellowOn
+                cell.textLabel?.text = LocalizedString("Lighten Yellow LED", comment: "The title of the cell showing Lighten Yellow LED")
+            case .red:
+                switchView.isHidden = false
+                cell.accessoryType = .none
+                switchView.isOn = redOn
+                cell.textLabel?.text = LocalizedString("Lighten Red LED", comment: "The title of the cell showing Lighten Red LED")
+            case .shake:
+                switchView.isHidden = false
+                switchView.isOn = shakeOn
+                cell.accessoryType = .none
+                cell.textLabel?.text = LocalizedString("Test Vibration", comment: "The title of the cell showing Test Vibration")
+            case .findDevice:
+                cell.textLabel?.text = LocalizedString("Find Device", comment: "The title of the cell for sounding device finding piezo")
+                cell.detailTextLabel?.text = nil
+            }
+        case .configureCommand:
+            switch OrangeConfigureCommandRow(rawValue: indexPath.row)! {
+            case .connectionLED:
+                switchView.isHidden = false
+                switchView.isOn = ledOn
+                cell.accessoryType = .none
+                cell.textLabel?.text = LocalizedString("Connection LED", comment: "The title of the cell for connection LED")
+            case .connectionVibrate:
+                switchView.isHidden = false
+                switchView.isOn = vibrationOn
+                cell.accessoryType = .none
+                cell.textLabel?.text = LocalizedString("Connection Vibration", comment: "The title of the cell for connection vibration")
+            }
         }
 
         return cell
     }
 
     public override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
-        switch Section(rawValue: section)! {
+        switch sections[section] {
         case .device:
             return LocalizedString("Device", comment: "The title of the section describing the device")
-        case .commands:
-            return LocalizedString("Commands", comment: "The title of the section describing commands")
+        case .rileyLinkCommands:
+            return LocalizedString("Test Commands", comment: "The title of the section for rileylink commands")
+        case .emaLinkCommands:
+            return LocalizedString("Test Commands", comment: "The title of the section for emalink commands")
+        case .orangeLinkCommands:
+            return LocalizedString("Test Commands", comment: "The title of the section for orangelink commands")
+        case .configureCommand:
+            return LocalizedString("Connection Monitoring", comment: "The title of the section for connection monitoring")
+        case .alert:
+            return LocalizedString("Alert", comment: "The title of the section for alerts")
         }
     }
 
     // MARK: - UITableViewDelegate
 
     public override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool {
-        switch Section(rawValue: indexPath.section)! {
+        switch sections[indexPath.section] {
         case .device:
-            switch DeviceRow(rawValue: indexPath.row)! {
+            switch deviceRows[indexPath.row] {
             case .customName:
                 return true
             default:
                 return false
             }
-        case .commands:
+        case .configureCommand:
+            return false
+        case .orangeLinkCommands:
+            switch OrangeLinkCommandRow(rawValue: indexPath.row)! {
+            case .findDevice:
+                return true
+            default:
+                return false
+            }
+        case .rileyLinkCommands:
+            return device.peripheralState == .connected
+        case .emaLinkCommands:
             return device.peripheralState == .connected
+        case .alert:
+            return true
         }
     }
 
     public override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
-        switch Section(rawValue: indexPath.section)! {
+        switch sections[indexPath.section] {
         case .device:
-            switch DeviceRow(rawValue: indexPath.row)! {
+            switch deviceRows[indexPath.row] {
             case .customName:
                 let vc = TextFieldTableViewController()
                 if let cell = tableView.cellForRow(at: indexPath) {
@@ -340,8 +718,83 @@ public class RileyLinkDeviceTableViewController: UITableViewController {
             default:
                 break
             }
-        case .commands:
+        case .rileyLinkCommands:
+            var vc: CommandResponseViewController?
+
+            switch RileyLinkCommandRow(rawValue: indexPath.row)! {
+            case .diagnosticLEDSMode:
+                let nextMode: RileyLinkLEDMode
+                switch ledMode {
+                case.on:
+                    nextMode = .off
+                default:
+                    nextMode = .on
+                }
+                vc = .setDiagnosticLEDMode(device: device, mode: nextMode)
+            case .getStatistics:
+                vc = .getStatistics(device: device)
+            }
+            if let cell = tableView.cellForRow(at: indexPath) {
+                vc?.title = cell.textLabel?.text
+            }
+
+            if let vc = vc {
+                show(vc, sender: indexPath)
+            }
+
+        case .emaLinkCommands:
+            var vc: CommandResponseViewController?
+
+            switch EmaLinkCommandRow(rawValue: indexPath.row)! {
+            case .logicLEDSMode:
+                let nextMode: RileyLinkLEDMode
+                switch ledMode {
+                case.on:
+                    nextMode = .off
+                default:
+                    nextMode = .on
+                }
+                vc = .setDiagnosticLEDMode(device: device, mode: nextMode)
+            case .getStatistics:
+                vc = .getStatistics(device: device)
+            }
+            if let cell = tableView.cellForRow(at: indexPath) {
+                vc?.title = cell.textLabel?.text
+            }
+
+            if let vc = vc {
+                show(vc, sender: indexPath)
+            }
+
+        case .orangeLinkCommands:
+            switch OrangeLinkCommandRow(rawValue: indexPath.row)! {
+            case .findDevice:
+                device.findDevice()
+                tableView.deselectRow(at: indexPath, animated: true)
+            default:
+                break
+            }
+        case .configureCommand:
             break
+        case .alert:
+            switch AlertRow(rawValue: indexPath.row)! {
+            case .battery:
+                let alert = UIAlertController.init(title: LocalizedString("Battery level Alert", comment: "Header of list showing battery level alert options"), message: nil, preferredStyle: .actionSheet)
+                let action = UIAlertAction.init(title: LocalizedString("OFF", comment: "Battery level alert OFF in list of options"), style: .default) { _ in
+                    self.batteryAlertLevel = nil
+                    self.tableView.reloadData()
+                }
+                alert.addAction(action)
+
+                for value in [20,30,40,50] {
+                    let action = UIAlertAction.init(title: "\(value)%", style: .default) { _ in
+                        self.batteryAlertLevel = value
+                        self.tableView.reloadData()
+                    }
+                    alert.addAction(action)
+                }
+                present(alert, animated: true, completion: nil)
+            }
         }
     }
 }
@@ -354,9 +807,9 @@ extension RileyLinkDeviceTableViewController: TextFieldTableViewControllerDelega
 
     public func textFieldTableViewControllerDidEndEditing(_ controller: TextFieldTableViewController) {
         if let indexPath = tableView.indexPathForSelectedRow {
-            switch Section(rawValue: indexPath.section)! {
+            switch sections[indexPath.section] {
             case .device:
-                switch DeviceRow(rawValue: indexPath.row)! {
+                switch deviceRows[indexPath.row] {
                 case .customName:
                     device.setCustomName(controller.value!)
                 default:
@@ -402,6 +855,14 @@ private extension UITableViewCell {
         }
     }
     
+    func setDetailBatteryLevel(_ batteryLevel: Int?) {
+        if let batteryLevel = batteryLevel {
+            detailTextLabel?.text = "\(batteryLevel)" + " %"
+        } else {
+            detailTextLabel?.text = ""
+        }
+    }
+    
     func setDetailFrequency(_ frequency: Measurement<UnitFrequency>?, formatter: MeasurementFormatter) {
         if let frequency = frequency {
             detailTextLabel?.text = formatter.string(from: frequency)
@@ -409,5 +870,29 @@ private extension UITableViewCell {
             detailTextLabel?.text = ""
         }
     }
-
+    
+    func setLEDMode(_ mode: RileyLinkLEDMode?) {
+        switch mode {
+        case .on:
+            detailTextLabel?.text = LocalizedString("On", comment: "Text indicating LED Mode is on")
+        case .off:
+            detailTextLabel?.text = LocalizedString("Off", comment: "Text indicating LED Mode is off")
+        case .auto:
+            detailTextLabel?.text = LocalizedString("Auto", comment: "Text indicating LED Mode is auto")
+        case .none:
+            detailTextLabel?.text = ""
+        }
+    }
+    
+    func setVoltage(_ voltage: Float?) {
+        if let voltage = voltage {
+            detailTextLabel?.text = String(format: "%.1f%", voltage)
+        } else {
+            detailTextLabel?.text = ""
+        }
+    }
+    
+    func setBatteryAlert(_ level: Int?, formatter: NumberFormatter) {
+        detailTextLabel?.text = formatter.percentString(from: level) ?? LocalizedString("Off", comment: "Detail text when battery alert disabled.")
+    }
 }

+ 64 - 0
Dependencies/rileylink_ios/RileyLinkKitUI/sv.lproj/Localizable.strings

@@ -37,5 +37,69 @@
 /* The title of the cell showing BLE signal strength (RSSI) */
 "Signal Strength" = "Signalstyrka";
 
+/* The header of the cells showing connection monitoring */
+"Connection Monitoring" = "Anslutningsstatus";
+
 /* The title of the cell showing uptime */
 "Uptime" = "Körs sedan";
+
+/* The title of the cell showing battery level */
+"Battery level" = "Batterinivå";
+
+/* The title of the cell showing Voltage */
+"Voltage" = "Spänning (V)";
+
+//* "The title of the section for alerts" */
+"Alert" = "Varning";
+
+/* The title of the cell showing Low Battery Alert */
+"Low Battery Alert" = "Låg batterinivå";
+
+/* Header of list showing battery level alert options */
+"Battery level Alert" = "Varning batterinivå";
+
+/* Battery level alert OFF in list of options */
+"OFF" = "AV";
+
+/* The title of the command to update diagnostic LEDs */
+"Diagnostic LEDs" = "Diagnostiska LED";
+
+/* The title of the command to fetch RileyLink statistics */
+"Get RileyLink Statistics" = "Hämta RileyLink-statistik";
+
+/* The title of the command to invert BLE connection LED logic */
+"Invert LED Logic" = "Invertera LED-Logik";
+
+/* The header of the cells showing test commands */
+"Test Commands" = "Testkommandon";
+
+/* The title of the cell showing Lighten Yellow LED */
+"Lighten Yellow LED" = "Ljusare gul LED";
+
+/* The title of the cell showing Lighten Yellow LED */
+"Lighten Red LED" = "Ljusare röd LED";
+
+/* The title of the cell showing Test Vibration */
+"Test Vibration" = "Testa vibration";
+
+/* The title of the cell for sounding device finding piezo */
+"Find Device" = "Hitta enhet";
+
+/* The title of the cell for connection LED */
+"Connection LED" = "Anslutnings-LED";
+
+/* The title of the cell for connection vibration */
+"Connection Vibration" = "Anslutningsvibration";
+
+/* Detail text when battery alert disabled. */
+"Off" = "Av";
+
+/* Text indicating LED Mode is on */
+"On" = "På";
+
+/* Text indicating LED Mode is off */
+"Off" = "Av";
+
+/* Text indicating LED Mode is auto */
+"Auto" = "Auto";
+

+ 3 - 0
FreeAPS/Sources/Modules/CGM/View/CGMRootView.swift

@@ -23,6 +23,9 @@ extension CGM {
                             .autocapitalization(.allCharacters)
                             .keyboardType(.asciiCapable)
                     }
+                    .onDisappear {
+                        viewModel.onChangeID()
+                    }
                 }
 
                 Section(header: Text("Other")) {

+ 1 - 1
scripts/swiftformat.sh

@@ -9,7 +9,7 @@ function assertEnvironment {
 
 assertEnvironment "${SRCROOT}" "Please set SRCROOT to project root folder"
 
-SDKROOT=macosx
+unset SDKROOT
 
 swift run -c release --package-path BuildTools swiftformat "${SRCROOT}" \
 --enable andOperator,\