RileyLinkBluetoothDevice.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  1. //
  2. // RileyLinkBluetoothDevice.swift
  3. // RileyLinkBLEKit
  4. //
  5. // Created by Pete Schwamb on 9/5/22.
  6. // Copyright © 2022 Pete Schwamb. All rights reserved.
  7. //
  8. import CoreBluetooth
  9. import os.log
  10. public class RileyLinkBluetoothDevice: RileyLinkDevice {
  11. private let manager: PeripheralManager
  12. private let log = OSLog(category: "RileyLinkDevice")
  13. // Confined to `manager.queue`
  14. private var bleFirmwareVersion: BLEFirmwareVersion?
  15. // Confined to `manager.queue`
  16. private var radioFirmwareVersion: RadioFirmwareVersion?
  17. public var isConnected: Bool {
  18. return manager.peripheral.state == .connected
  19. }
  20. func setPeripheral(_ peripheral: CBPeripheral) {
  21. manager.peripheral = peripheral
  22. }
  23. public var rlFirmwareDescription: String {
  24. let versions = [radioFirmwareVersion, bleFirmwareVersion].compactMap { (version: CustomStringConvertible?) -> String? in
  25. if let version = version {
  26. return String(describing: version)
  27. } else {
  28. return nil
  29. }
  30. }
  31. return versions.joined(separator: " / ")
  32. }
  33. private var version: String {
  34. switch hardwareType {
  35. case .riley, .ema, .none:
  36. return rlFirmwareDescription
  37. case .orange:
  38. return orangeLinkFirmwareHardwareVersion
  39. }
  40. }
  41. // Confined to `lock`
  42. private var idleListeningState: IdleListeningState = .disabled
  43. // Confined to `lock`
  44. private var lastIdle: Date?
  45. // Confined to `lock`
  46. // TODO: Tidy up this state/preference machine
  47. private var isIdleListeningPending = false
  48. // Confined to `lock`
  49. private var isTimerTickEnabled = true
  50. /// Serializes access to device state
  51. private var lock = os_unfair_lock()
  52. private var orangeLinkFirmwareHardwareVersion = "v1.x"
  53. private var orangeLinkHardwareVersionMajorMinor: [Int]?
  54. private var ledOn: Bool = false
  55. private var vibrationOn: Bool = false
  56. private var voltage: Float?
  57. private var batteryLevel: Int?
  58. private var hasPiezo: Bool {
  59. if let olHW = orangeLinkHardwareVersionMajorMinor, olHW[0] == 1, olHW[1] >= 1 {
  60. return true
  61. } else if let olHW = orangeLinkHardwareVersionMajorMinor, olHW[0] == 2, olHW[1] == 6 {
  62. return true
  63. }
  64. return false
  65. }
  66. public var hasOrangeLinkService: Bool {
  67. return self.manager.peripheral.services?.itemWithUUID(RileyLinkServiceUUID.orange.cbUUID) != nil
  68. }
  69. public var hardwareType: RileyLinkHardwareType? {
  70. guard let services = self.manager.peripheral.services else {
  71. return nil
  72. }
  73. guard let bleComponents = self.bleFirmwareVersion else {
  74. return nil
  75. }
  76. if services.itemWithUUID(RileyLinkServiceUUID.secureDFU.cbUUID) != nil {
  77. return .orange
  78. } else if bleComponents.components[0] == 3 {
  79. // this returns true for riley with ema firmware, but that is OK
  80. return .ema
  81. } else {
  82. // as long as riley ble remains at 2.x with ema at 3.x this will work
  83. return .riley
  84. }
  85. }
  86. /// The queue used to serialize sessions and observe when they've drained
  87. private let sessionQueue: OperationQueue = {
  88. let queue = OperationQueue()
  89. queue.name = "com.rileylink.RileyLinkBLEKit.RileyLinkDevice.sessionQueue"
  90. queue.maxConcurrentOperationCount = 1
  91. return queue
  92. }()
  93. private var sessionQueueOperationCountObserver: NSKeyValueObservation!
  94. public var rssi: Int?
  95. init(peripheralManager: PeripheralManager, rssi: Int?) {
  96. self.manager = peripheralManager
  97. self.rssi = rssi
  98. sessionQueue.underlyingQueue = peripheralManager.queue
  99. peripheralManager.delegate = self
  100. sessionQueueOperationCountObserver = sessionQueue.observe(\.operationCount, options: [.new]) { [weak self] (queue, change) in
  101. if let newValue = change.newValue, newValue == 0 {
  102. self?.log.debug("Session queue operation count is now empty")
  103. self?.assertIdleListening(forceRestart: true)
  104. }
  105. }
  106. }
  107. }
  108. // MARK: - Peripheral operations. Thread-safe.
  109. extension RileyLinkBluetoothDevice {
  110. public var name: String? {
  111. return manager.peripheral.name
  112. }
  113. public var deviceURI: String {
  114. return "rileylink://\(name ?? peripheralIdentifier.uuidString)"
  115. }
  116. public var peripheralIdentifier: UUID {
  117. return manager.peripheral.identifier
  118. }
  119. public var peripheralState: CBPeripheralState {
  120. return manager.peripheral.state
  121. }
  122. public func readRSSI() {
  123. guard case .connected = manager.peripheral.state, case .poweredOn? = manager.central?.state else {
  124. return
  125. }
  126. manager.peripheral.readRSSI()
  127. }
  128. public func setCustomName(_ name: String) {
  129. manager.setCustomName(name)
  130. }
  131. public func updateBatteryLevel() {
  132. manager.readBatteryLevel { value in
  133. if let batteryLevel = value {
  134. self.batteryLevel = batteryLevel
  135. NotificationCenter.default.post(
  136. name: .DeviceBatteryLevelUpdated,
  137. object: self,
  138. userInfo: [RileyLinkBluetoothDevice.batteryLevelKey: batteryLevel]
  139. )
  140. NotificationCenter.default.post(name: .DeviceStatusUpdated, object: self)
  141. }
  142. }
  143. }
  144. public func orangeAction(_ command: OrangeLinkCommand) {
  145. log.debug("orangeAction: %@", "\(command)")
  146. manager.orangeAction(command)
  147. }
  148. public func setOrangeConfig(_ config: OrangeLinkConfigurationSetting, isOn: Bool) {
  149. log.debug("setOrangeConfig: %@, %@", "\(String(describing: config))", "\(isOn)")
  150. manager.setOrangeConfig(config, isOn: isOn)
  151. }
  152. public func orangeWritePwd() {
  153. log.debug("orangeWritePwd")
  154. manager.orangeWritePwd()
  155. }
  156. public func orangeClose() {
  157. log.debug("orangeClose")
  158. manager.orangeClose()
  159. }
  160. public func orangeReadSet() {
  161. log.debug("orangeReadSet")
  162. manager.orangeReadSet()
  163. }
  164. public func orangeReadVDC() {
  165. log.debug("orangeReadVDC")
  166. manager.orangeReadVDC()
  167. }
  168. public func findDevice() {
  169. log.debug("findDevice")
  170. manager.findDevice()
  171. }
  172. public func setDiagnosticeLEDModeForBLEChip(_ mode: RileyLinkLEDMode) {
  173. manager.setLEDMode(mode: mode)
  174. }
  175. public func readDiagnosticLEDModeForBLEChip(completion: @escaping (RileyLinkLEDMode?) -> Void) {
  176. manager.readDiagnosticLEDMode(completion: completion)
  177. }
  178. /// Asserts that the caller is currently on the session queue
  179. public func assertOnSessionQueue() {
  180. dispatchPrecondition(condition: .onQueue(manager.queue))
  181. }
  182. /// Schedules a closure to execute on the session queue after a specified time
  183. ///
  184. /// - Parameters:
  185. /// - deadline: The time after which to execute
  186. /// - execute: The closure to execute
  187. public func sessionQueueAsyncAfter(deadline: DispatchTime, execute: @escaping () -> Void) {
  188. manager.queue.asyncAfter(deadline: deadline, execute: execute)
  189. }
  190. }
  191. extension RileyLinkBluetoothDevice: Equatable, Hashable {
  192. public static func ==(lhs: RileyLinkBluetoothDevice, rhs: RileyLinkBluetoothDevice) -> Bool {
  193. return lhs === rhs
  194. }
  195. public func hash(into hasher: inout Hasher) {
  196. hasher.combine(peripheralIdentifier)
  197. }
  198. }
  199. // MARK: - Status management
  200. extension RileyLinkBluetoothDevice {
  201. public func getStatus(_ completion: @escaping (_ status: RileyLinkDeviceStatus) -> Void) {
  202. os_unfair_lock_lock(&lock)
  203. let lastIdle = self.lastIdle
  204. os_unfair_lock_unlock(&lock)
  205. self.manager.queue.async {
  206. completion(RileyLinkDeviceStatus(
  207. lastIdle: lastIdle,
  208. name: self.name,
  209. version: self.version,
  210. ledOn: self.ledOn,
  211. vibrationOn: self.vibrationOn,
  212. voltage: self.voltage,
  213. battery: self.batteryLevel,
  214. hasPiezo: self.hasPiezo
  215. ))
  216. }
  217. }
  218. }
  219. // MARK: - Command session management
  220. // CommandSessions are a way to serialize access to the RileyLink command/response facility.
  221. // All commands that send data out on the RL data characteristic need to be in a command session.
  222. // Accessing other characteristics on the RileyLink can be done without a command session.
  223. extension RileyLinkBluetoothDevice {
  224. public func runSession(withName name: String, _ block: @escaping (_ session: CommandSession) -> Void) {
  225. self.log.default("Scheduling session %{public}@", name)
  226. sessionQueue.addOperation(manager.configureAndRun({ [weak self] (manager) in
  227. self?.log.default("======================== %{public}@ ===========================", name)
  228. let bleFirmwareVersion = self?.bleFirmwareVersion
  229. let radioFirmwareVersion = self?.radioFirmwareVersion
  230. if bleFirmwareVersion == nil || radioFirmwareVersion == nil {
  231. self?.log.error("Running session with incomplete configuration: bleFirmwareVersion %{public}@, radioFirmwareVersion: %{public}@", String(describing: bleFirmwareVersion), String(describing: radioFirmwareVersion))
  232. }
  233. block(CommandSession(manager: manager, responseType: bleFirmwareVersion?.responseType ?? .buffered, firmwareVersion: radioFirmwareVersion ?? .unknown))
  234. self?.log.default("------------------------ %{public}@ ---------------------------", name)
  235. }))
  236. }
  237. }
  238. // MARK: - Idle management
  239. extension RileyLinkBluetoothDevice {
  240. public enum IdleListeningState {
  241. case enabled(timeout: TimeInterval, channel: UInt8)
  242. case disabled
  243. }
  244. func setIdleListeningState(_ state: IdleListeningState) {
  245. os_unfair_lock_lock(&lock)
  246. let oldValue = idleListeningState
  247. idleListeningState = state
  248. os_unfair_lock_unlock(&lock)
  249. switch (oldValue, state) {
  250. case (.disabled, .enabled):
  251. assertIdleListening(forceRestart: true)
  252. case (.enabled, .enabled):
  253. assertIdleListening(forceRestart: false)
  254. default:
  255. break
  256. }
  257. }
  258. public func assertIdleListening(forceRestart: Bool = false) {
  259. os_unfair_lock_lock(&lock)
  260. guard case .enabled(timeout: let timeout, channel: let channel) = self.idleListeningState else {
  261. os_unfair_lock_unlock(&lock)
  262. return
  263. }
  264. guard case .connected = self.manager.peripheral.state, case .poweredOn? = self.manager.central?.state else {
  265. os_unfair_lock_unlock(&lock)
  266. return
  267. }
  268. guard forceRestart || (self.lastIdle ?? .distantPast).timeIntervalSinceNow < -timeout else {
  269. os_unfair_lock_unlock(&lock)
  270. return
  271. }
  272. guard !self.isIdleListeningPending else {
  273. os_unfair_lock_unlock(&lock)
  274. return
  275. }
  276. self.isIdleListeningPending = true
  277. os_unfair_lock_unlock(&lock)
  278. self.manager.startIdleListening(idleTimeout: timeout, channel: channel) { (error) in
  279. os_unfair_lock_lock(&self.lock)
  280. self.isIdleListeningPending = false
  281. if let error = error {
  282. self.log.error("Unable to start idle listening: %@", String(describing: error))
  283. os_unfair_lock_unlock(&self.lock)
  284. } else {
  285. self.lastIdle = Date()
  286. self.log.debug("Started idle listening")
  287. os_unfair_lock_unlock(&self.lock)
  288. NotificationCenter.default.post(name: .DeviceDidStartIdle, object: self)
  289. }
  290. }
  291. }
  292. }
  293. // MARK: - Timer tick management
  294. extension RileyLinkBluetoothDevice {
  295. func setTimerTickEnabled(_ enabled: Bool) {
  296. os_unfair_lock_lock(&lock)
  297. self.isTimerTickEnabled = enabled
  298. os_unfair_lock_unlock(&lock)
  299. self.assertTimerTick()
  300. }
  301. func assertTimerTick() {
  302. os_unfair_lock_lock(&self.lock)
  303. let isTimerTickEnabled = self.isTimerTickEnabled
  304. os_unfair_lock_unlock(&self.lock)
  305. if isTimerTickEnabled != self.manager.timerTickEnabled {
  306. self.manager.setTimerTickEnabled(isTimerTickEnabled)
  307. }
  308. }
  309. }
  310. // MARK: - CBCentralManagerDelegate Proxying
  311. extension RileyLinkBluetoothDevice {
  312. func centralManagerDidUpdateState(_ central: CBCentralManager) {
  313. if case .poweredOn = central.state {
  314. assertIdleListening(forceRestart: false)
  315. assertTimerTick()
  316. }
  317. manager.centralManagerDidUpdateState(central)
  318. }
  319. func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
  320. log.default("didConnect %{public}@", peripheral)
  321. if case .connected = peripheral.state {
  322. assertIdleListening(forceRestart: false)
  323. assertTimerTick()
  324. }
  325. manager.centralManager(central, didConnect: peripheral)
  326. NotificationCenter.default.post(name: .DeviceConnectionStateDidChange, object: self)
  327. }
  328. func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
  329. log.default("didDisconnectPeripheral %{public}@", peripheral)
  330. NotificationCenter.default.post(name: .DeviceConnectionStateDidChange, object: self)
  331. }
  332. func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
  333. log.default("didFailToConnect %{public}@", peripheral)
  334. NotificationCenter.default.post(name: .DeviceConnectionStateDidChange, object: self)
  335. }
  336. }
  337. extension RileyLinkBluetoothDevice: PeripheralManagerDelegate {
  338. func peripheralManager(_ manager: PeripheralManager, didUpdateNotificationStateFor characteristic: CBCharacteristic) {
  339. log.debug("Did didUpdateNotificationStateFor %@", characteristic)
  340. }
  341. // If PeripheralManager receives a response on the data queue, without an outstanding request,
  342. // it will pass the update to this method, which is called on the central's queue.
  343. // This is how idle listen responses are handled
  344. func peripheralManager(_ manager: PeripheralManager, didUpdateValueFor characteristic: CBCharacteristic) {
  345. let characteristicService: CBService? = characteristic.service
  346. guard let cbService = characteristicService, let service = RileyLinkServiceUUID(rawValue: cbService.uuid.uuidString) else {
  347. log.debug("Update from characteristic on unknown service: %@", String(describing: characteristic.service))
  348. return
  349. }
  350. switch service {
  351. case .main:
  352. guard let mainCharacteristic = MainServiceCharacteristicUUID(rawValue: characteristic.uuid.uuidString) else {
  353. log.debug("Update from unknown characteristic %@ on main service.", characteristic.uuid.uuidString)
  354. return
  355. }
  356. handleCharacteristicUpdate(mainCharacteristic, value: characteristic.value)
  357. case .orange:
  358. guard let orangeCharacteristic = OrangeServiceCharacteristicUUID(rawValue: characteristic.uuid.uuidString) else {
  359. log.debug("Update from unknown characteristic %@ on orange service.", characteristic.uuid.uuidString)
  360. return
  361. }
  362. handleCharacteristicUpdate(orangeCharacteristic, value: characteristic.value)
  363. default:
  364. return
  365. }
  366. }
  367. private func handleCharacteristicUpdate(_ characteristic: MainServiceCharacteristicUUID, value: Data?) {
  368. switch characteristic {
  369. case .data:
  370. guard let value = value, value.count > 0 else {
  371. return
  372. }
  373. self.manager.queue.async {
  374. if let responseType = self.bleFirmwareVersion?.responseType {
  375. let response: PacketResponse?
  376. switch responseType {
  377. case .buffered:
  378. var buffer = ResponseBuffer<PacketResponse>(endMarker: 0x00)
  379. buffer.append(value)
  380. response = buffer.responses.last
  381. case .single:
  382. response = PacketResponse(data: value)
  383. }
  384. if let response = response {
  385. switch response.code {
  386. case .commandInterrupted:
  387. self.log.debug("Received commandInterrupted during idle; assuming device is still listening.")
  388. return
  389. case .rxTimeout, .zeroData, .invalidParam, .unknownCommand:
  390. self.log.debug("Idle error received: %@", String(describing: response.code))
  391. case .success:
  392. if let packet = response.packet {
  393. self.log.default("Idle packet received: %{public}@", String(describing: packet))
  394. NotificationCenter.default.post(
  395. name: .DevicePacketReceived,
  396. object: self,
  397. userInfo: [RileyLinkBluetoothDevice.notificationPacketKey: packet]
  398. )
  399. }
  400. }
  401. } else {
  402. self.log.error("Unknown idle response: %{public}@", value.hexadecimalString)
  403. }
  404. } else {
  405. self.log.error("Skipping parsing characteristic value update due to missing BLE firmware version")
  406. }
  407. self.assertIdleListening(forceRestart: true)
  408. }
  409. case .responseCount:
  410. // PeripheralManager.Configuration.valueUpdateMacros is responsible for handling this response.
  411. break
  412. case .timerTick:
  413. NotificationCenter.default.post(name: .DeviceTimerDidTick, object: self)
  414. assertIdleListening(forceRestart: false)
  415. case .customName, .firmwareVersion, .ledMode:
  416. break
  417. }
  418. }
  419. private func handleCharacteristicUpdate(_ characteristic: OrangeServiceCharacteristicUUID, value: Data?) {
  420. switch characteristic {
  421. case .orangeRX, .orangeTX:
  422. guard let data = value, !data.isEmpty else { return }
  423. if data.first == 0xbb {
  424. guard data.count > 6 else { return }
  425. if data[1] == 0x09, data[2] == 0xaa {
  426. orangeLinkFirmwareHardwareVersion = "FW\(data[3]).\(data[4])/HW\(data[5]).\(data[6])"
  427. orangeLinkHardwareVersionMajorMinor = [Int(data[5]), Int(data[6])]
  428. NotificationCenter.default.post(name: .DeviceStatusUpdated, object: self)
  429. }
  430. } else if data.first == OrangeLinkRequestType.cfgHeader.rawValue {
  431. guard data.count > 2 else { return }
  432. if data[1] == 0x01 {
  433. guard data.count > 5 else { return }
  434. ledOn = (data[3] != 0)
  435. vibrationOn = (data[4] != 0)
  436. NotificationCenter.default.post(name: .DeviceStatusUpdated, object: self)
  437. } else if data[1] == 0x03 {
  438. guard data.count > 4 else { return }
  439. let int = UInt16(bigEndian: Data(data[3...4]).withUnsafeBytes { $0.load(as: UInt16.self) })
  440. voltage = Float(int) / 1000
  441. NotificationCenter.default.post(name: .DeviceStatusUpdated, object: self)
  442. }
  443. }
  444. }
  445. }
  446. func peripheralManager(_ manager: PeripheralManager, didReadRSSI RSSI: NSNumber, error: Error?) {
  447. self.rssi = Int(truncating: RSSI)
  448. NotificationCenter.default.post(
  449. name: .DeviceRSSIDidChange,
  450. object: self,
  451. userInfo: [RileyLinkBluetoothDevice.notificationRSSIKey: RSSI]
  452. )
  453. }
  454. func peripheralManagerDidUpdateName(_ manager: PeripheralManager) {
  455. NotificationCenter.default.post(
  456. name: .DeviceNameDidChange,
  457. object: self,
  458. userInfo: nil
  459. )
  460. }
  461. func completeConfiguration(for manager: PeripheralManager) throws {
  462. // Read bluetooth version to determine compatibility
  463. log.default("Reading firmware versions for PeripheralManager configuration")
  464. let bleVersionString = try manager.readBluetoothFirmwareVersion(timeout: 1)
  465. bleFirmwareVersion = BLEFirmwareVersion(versionString: bleVersionString)
  466. let radioVersionString = try manager.readRadioFirmwareVersion(timeout: 1, responseType: bleFirmwareVersion?.responseType ?? .buffered)
  467. radioFirmwareVersion = RadioFirmwareVersion(versionString: radioVersionString)
  468. try manager.setOrangeNotifyOn()
  469. }
  470. }
  471. extension RileyLinkBluetoothDevice: CustomDebugStringConvertible {
  472. public var debugDescription: String {
  473. os_unfair_lock_lock(&lock)
  474. let lastIdle = self.lastIdle
  475. let isIdleListeningPending = self.isIdleListeningPending
  476. let isTimerTickEnabled = self.isTimerTickEnabled
  477. os_unfair_lock_unlock(&lock)
  478. return [
  479. "## RileyLinkDevice",
  480. "* name: \(name ?? "")",
  481. "* lastIdle: \(lastIdle ?? .distantPast)",
  482. "* isIdleListeningPending: \(isIdleListeningPending)",
  483. "* isTimerTickEnabled: \(isTimerTickEnabled)",
  484. "* isTimerTickNotifying: \(manager.timerTickEnabled)",
  485. "* radioFirmware: \(String(describing: radioFirmwareVersion))",
  486. "* bleFirmware: \(String(describing: bleFirmwareVersion))",
  487. "* peripheralManager: \(manager)",
  488. "* sessionQueue.operationCount: \(sessionQueue.operationCount)"
  489. ].joined(separator: "\n")
  490. }
  491. }
  492. extension RileyLinkBluetoothDevice {
  493. public static let notificationPacketKey = "com.rileylink.RileyLinkBLEKit.RileyLinkDevice.NotificationPacket"
  494. public static let notificationRSSIKey = "com.rileylink.RileyLinkBLEKit.RileyLinkDevice.NotificationRSSI"
  495. public static let batteryLevelKey = "com.rileylink.RileyLinkBLEKit.RileyLinkDevice.BatteryLevel"
  496. }
  497. extension Notification.Name {
  498. public static let DeviceConnectionStateDidChange = Notification.Name(rawValue: "com.rileylink.RileyLinkBLEKit.ConnectionStateDidChange")
  499. public static let DeviceDidStartIdle = Notification.Name(rawValue: "com.rileylink.RileyLinkBLEKit.DidStartIdle")
  500. public static let DeviceNameDidChange = Notification.Name(rawValue: "com.rileylink.RileyLinkBLEKit.NameDidChange")
  501. public static let DevicePacketReceived = Notification.Name(rawValue: "com.rileylink.RileyLinkBLEKit.PacketReceived")
  502. public static let DeviceRSSIDidChange = Notification.Name(rawValue: "com.rileylink.RileyLinkBLEKit.RSSIDidChange")
  503. public static let DeviceTimerDidTick = Notification.Name(rawValue: "com.rileylink.RileyLinkBLEKit.TimerTickDidChange")
  504. public static let DeviceStatusUpdated = Notification.Name(rawValue: "com.rileylink.RileyLinkBLEKit.DeviceStatusUpdated")
  505. public static let DeviceBatteryLevelUpdated = Notification.Name(rawValue: "com.rileylink.RileyLinkBLEKit.BatteryLevelUpdated")
  506. }