Просмотр исходного кода

Rl ema orange updates (#94)

* add new file for ema and orange and add to RileyLink.xcodeproj/project.pbxproj

* incorporate changes RL comms from ps2:loop-release/v2.2.5 + ema_orange_patch ps2:issue 686

* add changes to minimed to handle notifications for ema/orange battery
Marion Barker 4 лет назад
Родитель
Сommit
d269ea76ce
24 измененных файлов с 1296 добавлено и 206 удалено
  1. 9 1
      Dependencies/rileylink_ios/Common/NumberFormatter.swift
  2. 37 0
      Dependencies/rileylink_ios/MinimedKit/PumpManager/MinimedPumpManager.swift
  3. 8 0
      Dependencies/rileylink_ios/MinimedKit/PumpManager/MinimedPumpManagerState.swift
  4. 1 1
      Dependencies/rileylink_ios/MinimedKit/PumpManager/PumpMessageSender.swift
  5. 1 1
      Dependencies/rileylink_ios/MinimedKit/PumpManager/RileyLinkDevice.swift
  6. 1 1
      Dependencies/rileylink_ios/MinimedKitTests/PumpOpsSynchronousTests.swift
  7. 0 20
      Dependencies/rileylink_ios/MinimedKitUI/CommandResponseViewController.swift
  8. 10 2
      Dependencies/rileylink_ios/MinimedKitUI/MinimedPumpSettingsViewController.swift
  9. 3 2
      Dependencies/rileylink_ios/MinimedKitUI/RileyLinkMinimedDeviceTableViewController.swift
  10. 38 1
      Dependencies/rileylink_ios/OmniKit/PumpManager/OmnipodPumpManager.swift
  11. 12 1
      Dependencies/rileylink_ios/OmniKit/PumpManager/OmnipodPumpManagerState.swift
  12. 14 1
      Dependencies/rileylink_ios/OmniKitUI/ViewControllers/OmnipodSettingsViewController.swift
  13. 4 0
      Dependencies/rileylink_ios/RileyLink.xcodeproj/project.pbxproj
  14. 13 4
      Dependencies/rileylink_ios/RileyLinkBLEKit/CommandSession.swift
  15. 247 16
      Dependencies/rileylink_ios/RileyLinkBLEKit/PeripheralManager+RileyLink.swift
  16. 26 35
      Dependencies/rileylink_ios/RileyLinkBLEKit/PeripheralManager.swift
  17. 16 7
      Dependencies/rileylink_ios/RileyLinkBLEKit/PeripheralManagerError.swift
  18. 228 19
      Dependencies/rileylink_ios/RileyLinkBLEKit/RileyLinkDevice.swift
  19. 6 1
      Dependencies/rileylink_ios/RileyLinkBLEKit/RileyLinkDeviceError.swift
  20. 3 7
      Dependencies/rileylink_ios/RileyLinkKit/PumpOpsSession.swift
  21. 0 16
      Dependencies/rileylink_ios/RileyLinkKit/RileyLinkDevice.swift
  22. 2 0
      Dependencies/rileylink_ios/RileyLinkKit/RileyLinkPumpManager.swift
  23. 62 0
      Dependencies/rileylink_ios/RileyLinkKitUI/CommandResponseViewController.swift
  24. 555 70
      Dependencies/rileylink_ios/RileyLinkKitUI/RileyLinkDeviceTableViewController.swift

+ 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))
     }

+ 37 - 0
Dependencies/rileylink_ios/MinimedKit/PumpManager/MinimedPumpManager.swift

@@ -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 {

Разница между файлами не показана из-за своего большого размера
+ 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/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:

+ 38 - 1
Dependencies/rileylink_ios/OmniKit/PumpManager/OmnipodPumpManager.swift

@@ -20,7 +20,7 @@ public enum ReservoirAlertState {
     case empty
 }
 
-public protocol PodStateObserver: class {
+public protocol PodStateObserver: AnyObject {
     func podStateDidUpdate(_ state: PodState?)
 }
 
@@ -229,6 +229,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 {

+ 12 - 1
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 {
@@ -174,7 +181,9 @@ public struct OmnipodPumpManagerState: RawRepresentable, Equatable {
         if let pairingAttemptAddress = pairingAttemptAddress {
             value["pairingAttemptAddress"] = pairingAttemptAddress
         }
-        
+        value["rileyLinkBatteryAlertLevel"] = rileyLinkBatteryAlertLevel
+        value["lastRileyLinkBatteryAlertDate"] = lastRileyLinkBatteryAlertDate
+
         return value
     }
 }
@@ -213,6 +222,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")

+ 14 - 1
Dependencies/rileylink_ios/OmniKitUI/ViewControllers/OmnipodSettingsViewController.swift

@@ -632,7 +632,20 @@ 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 .deletePumpManager:
             let confirmVC = UIAlertController(pumpManagerDeletionHandler: {

+ 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)
         }
     }
 

+ 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 {

+ 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 = NSLocalizedString("Battery level", comment: "The title of the cell showing battery level")
+                cell.setDetailBatteryLevel(battery)
+            case .voltage:
+                cell.textLabel?.text = NSLocalizedString("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 = NSLocalizedString("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 = NSLocalizedString("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 = NSLocalizedString("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 = NSLocalizedString("Test Vibration", comment: "The title of the cell showing Test Vibration")
+            case .findDevice:
+                cell.textLabel?.text = NSLocalizedString("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 = NSLocalizedString("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 = NSLocalizedString("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: "Battery level Alert", message: nil, preferredStyle: .actionSheet)
+                let action = UIAlertAction.init(title: "OFF", 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) ?? NSLocalizedString("Off", comment: "Detail text when battery alert disabled.")
+    }
 }