Alternative device connection
This commit is contained in:
parent
7cd697fb01
commit
8b4c4800c9
46
TempTrack/Connection/BluetoothRequestType.swift
Normal file
46
TempTrack/Connection/BluetoothRequestType.swift
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BluetoothRequestType: UInt8 {
|
||||||
|
/**
|
||||||
|
* Request the number of bytes already recorded
|
||||||
|
*
|
||||||
|
* Request:
|
||||||
|
* - No additional bytes expected
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* - `BluetoothResponseType.success`
|
||||||
|
* - the number of recorded bytes as a `Uint16` (2 bytes)
|
||||||
|
* - the number of seconds until the next measurement as a `Uint16` (2 bytes)
|
||||||
|
* - the number of seconds between measurements as a `Uint16` (2 bytes)
|
||||||
|
* - the number of measurements as a `Uint16` (2 bytes)
|
||||||
|
* - the maximum number of bytes to request as a `Uint16` (2 bytes)
|
||||||
|
* - the number of seconds since power on as a `Uint32` (4 bytes)
|
||||||
|
*/
|
||||||
|
case getInfo = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request recording data
|
||||||
|
*
|
||||||
|
* Request:
|
||||||
|
* - Bytes 1-2: Memory offset (`UInt16`)
|
||||||
|
* - Bytes 3-4: Number of bytes (`UInt16`)
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* - `BluetoothResponseType.success`, plus the requested bytes
|
||||||
|
* - `BluetoothResponseType.responseTooLarge` if too many bytes are requested
|
||||||
|
*/
|
||||||
|
case getRecordingData = 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request deletion of recordings
|
||||||
|
*
|
||||||
|
* Request:
|
||||||
|
* - Bytes 1-2: Number of bytes to clear (uint16_t)
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* - `BluetoothResponseType.success`
|
||||||
|
* - `BluetoothResponseType.invalidNumberOfBytesToDelete`, if the number of bytes does not match.
|
||||||
|
* This may happen when a new temperature recording is performed in between calls
|
||||||
|
*/
|
||||||
|
case clearRecordingBuffer = 2
|
||||||
|
}
|
334
TempTrack/Connection/DeviceConnection.swift
Normal file
334
TempTrack/Connection/DeviceConnection.swift
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreBluetooth
|
||||||
|
|
||||||
|
final class DeviceConnection: NSObject, CBCentralManagerDelegate, ObservableObject {
|
||||||
|
|
||||||
|
static let serviceUUID = CBUUID(string: "22071991-cccc-cccc-cccc-000000000001")
|
||||||
|
|
||||||
|
static let characteristicUUID = CBUUID(string: "22071991-cccc-cccc-cccc-000000000002")
|
||||||
|
|
||||||
|
private var manager: CBCentralManager! = nil
|
||||||
|
|
||||||
|
private(set) var lastRSSI: Int = 0 // TODO: Provide function to update
|
||||||
|
|
||||||
|
@Published
|
||||||
|
var state: DeviceState = .disconnected
|
||||||
|
|
||||||
|
var isConnected: Bool {
|
||||||
|
// Automatically updates with device state
|
||||||
|
if case .configured = state {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
self.manager = CBCentralManager(delegate: self, queue: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Allow the client to scan for devices and connect to the first device found with the correct characteristic
|
||||||
|
*/
|
||||||
|
@discardableResult
|
||||||
|
func initiateDeviceConnection() -> Bool {
|
||||||
|
switch state {
|
||||||
|
case .bluetoothDisabled:
|
||||||
|
log.info("Can't connect, bluetooth disabled")
|
||||||
|
return false
|
||||||
|
case .disconnected:
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
guard !manager.isScanning else {
|
||||||
|
state = .scanning
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
shouldConnectIfPossible = true
|
||||||
|
state = .scanning
|
||||||
|
manager.scanForPeripherals(withServices: [DeviceManager.serviceUUID])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func updateRSSIForConnectedDevice() -> Bool {
|
||||||
|
guard let device = state.device else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
device.readRSSI()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Indicate that a connection should be attempted when found.
|
||||||
|
|
||||||
|
This does not necessarily indicate that the phone is scanning for devices.
|
||||||
|
*/
|
||||||
|
@Published
|
||||||
|
var shouldConnectIfPossible = true {
|
||||||
|
didSet {
|
||||||
|
guard oldValue != shouldConnectIfPossible else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateConnectionOnChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateConnectionOnChange() {
|
||||||
|
if shouldConnectIfPossible {
|
||||||
|
ensureConnection()
|
||||||
|
} else {
|
||||||
|
disconnectIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureConnection() {
|
||||||
|
switch state {
|
||||||
|
case .disconnected:
|
||||||
|
initiateDeviceConnection()
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func disconnectIfNeeded() {
|
||||||
|
switch state {
|
||||||
|
case .bluetoothDisabled, .disconnected:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect() {
|
||||||
|
shouldConnectIfPossible = false
|
||||||
|
switch state {
|
||||||
|
case .bluetoothDisabled, .disconnected:
|
||||||
|
return
|
||||||
|
case .scanning:
|
||||||
|
manager.stopScan()
|
||||||
|
state = .disconnected
|
||||||
|
return
|
||||||
|
case .connecting(let device),
|
||||||
|
.discoveringCharacteristic(let device),
|
||||||
|
.discoveringServices(device: let device),
|
||||||
|
.configured(let device, _):
|
||||||
|
manager.cancelPeripheralConnection(device)
|
||||||
|
manager.stopScan()
|
||||||
|
state = .disconnected
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var requestContinuation: CheckedContinuation<Data?, Never>?
|
||||||
|
|
||||||
|
func getInfo() async -> DeviceInfo? {
|
||||||
|
await get(DeviceInfoRequest())
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDeviceData(offset: Int, count: Int) async -> Data? {
|
||||||
|
await get(DeviceDataRequest(offset: offset, count: count))
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteDeviceData(byteCount: Int) async -> Bool {
|
||||||
|
await get(DeviceDataResetRequest(byteCount: byteCount)) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func get<Request>(_ request: Request) async -> Request.Response? where Request: DeviceRequest {
|
||||||
|
guard requestContinuation == nil else {
|
||||||
|
// Prevent parallel requests
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard case .configured(let device, let characteristic) = state else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let requestData = Data([request.type.rawValue]) + request.payload
|
||||||
|
let responseData: Data? = await withCheckedContinuation { continuation in
|
||||||
|
requestContinuation = continuation
|
||||||
|
device.writeValue(requestData, for: characteristic, type: .withResponse)
|
||||||
|
device.readValue(for: characteristic)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) { [weak self] in
|
||||||
|
guard let requestContinuation = self?.requestContinuation else { return }
|
||||||
|
log.info("Timed out for request \(request.type)")
|
||||||
|
requestContinuation.resume(returning: nil)
|
||||||
|
self?.requestContinuation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let responseData else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let responseCode = responseData.first else {
|
||||||
|
log.error("Request \(request.type) got response of zero bytes")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let responseType = BluetoothResponseType(rawValue: responseCode) else {
|
||||||
|
log.error("Request \(request.type) got unknown response code \(responseCode)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch responseType {
|
||||||
|
case .success, .responseTooLarge, .invalidNumberOfBytesToDelete:
|
||||||
|
break
|
||||||
|
case .invalidCommand:
|
||||||
|
log.error("Request \(request.type) failed: Invalid command")
|
||||||
|
return nil
|
||||||
|
case .unknownCommand:
|
||||||
|
log.error("Request \(request.type) failed: Unknown command")
|
||||||
|
return nil
|
||||||
|
case .responseInProgress:
|
||||||
|
log.info("Request \(request.type) failed: Device is busy")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return request.makeResponse(from: responseData.dropFirst(), responseType: responseType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
|
||||||
|
guard shouldConnectIfPossible else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peripheral.delegate = self
|
||||||
|
manager.connect(peripheral)
|
||||||
|
manager.stopScan()
|
||||||
|
state = .connecting(device: peripheral)
|
||||||
|
}
|
||||||
|
|
||||||
|
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||||
|
switch central.state {
|
||||||
|
case .poweredOff:
|
||||||
|
state = .bluetoothDisabled
|
||||||
|
case .poweredOn:
|
||||||
|
state = .disconnected
|
||||||
|
initiateDeviceConnection()
|
||||||
|
case .unsupported:
|
||||||
|
state = .bluetoothDisabled
|
||||||
|
log.info("Bluetooth is not supported")
|
||||||
|
case .unknown:
|
||||||
|
state = .bluetoothDisabled
|
||||||
|
log.info("Bluetooth state is unknown")
|
||||||
|
case .resetting:
|
||||||
|
state = .bluetoothDisabled
|
||||||
|
log.info("Bluetooth is resetting")
|
||||||
|
case .unauthorized:
|
||||||
|
state = .bluetoothDisabled
|
||||||
|
log.info("Bluetooth is not authorized")
|
||||||
|
@unknown default:
|
||||||
|
state = .bluetoothDisabled
|
||||||
|
log.warning("Unknown state \(central.state)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||||
|
log.info("Connected to " + peripheral.name!)
|
||||||
|
peripheral.discoverServices([DeviceManager.serviceUUID])
|
||||||
|
state = .discoveringServices(device: peripheral)
|
||||||
|
}
|
||||||
|
|
||||||
|
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||||||
|
log.info("Disconnected from " + peripheral.name!)
|
||||||
|
state = .disconnected
|
||||||
|
// Attempt to reconnect
|
||||||
|
if shouldConnectIfPossible {
|
||||||
|
initiateDeviceConnection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
||||||
|
log.warning("Failed to connect device '\(peripheral.name ?? "NO_NAME")'")
|
||||||
|
if let error = error {
|
||||||
|
log.warning(error.localizedDescription)
|
||||||
|
}
|
||||||
|
state = manager.isScanning ? .scanning : .disconnected
|
||||||
|
// Attempt to reconnect
|
||||||
|
if shouldConnectIfPossible {
|
||||||
|
initiateDeviceConnection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DeviceConnection: CBPeripheralDelegate {
|
||||||
|
|
||||||
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||||
|
guard let services = peripheral.services, !services.isEmpty else {
|
||||||
|
log.error("No services found for device '\(peripheral.name ?? "NO_NAME")'")
|
||||||
|
manager.cancelPeripheralConnection(peripheral)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let service = services.first(where: { $0.uuid.uuidString == DeviceManager.serviceUUID.uuidString }) else {
|
||||||
|
log.error("Required service not found for '\(peripheral.name ?? "NO_NAME")': \(services.map { $0.uuid.uuidString})")
|
||||||
|
manager.cancelPeripheralConnection(peripheral)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peripheral.discoverCharacteristics([DeviceManager.characteristicUUID], for: service)
|
||||||
|
state = .discoveringCharacteristic(device: peripheral)
|
||||||
|
}
|
||||||
|
|
||||||
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||||||
|
if let error = error {
|
||||||
|
log.error("Failed to discover characteristics: \(error)")
|
||||||
|
manager.cancelPeripheralConnection(peripheral)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let characteristics = service.characteristics, !characteristics.isEmpty else {
|
||||||
|
log.error("No characteristics found for device")
|
||||||
|
manager.cancelPeripheralConnection(peripheral)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for characteristic in characteristics {
|
||||||
|
guard characteristic.uuid == DeviceManager.characteristicUUID else {
|
||||||
|
log.warning("Unused characteristic \(characteristic.uuid.uuidString)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
state = .configured(device: peripheral, characteristic: characteristic)
|
||||||
|
peripheral.setNotifyValue(true, for: characteristic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||||
|
if let error = error {
|
||||||
|
log.error("Peripheral failed to write value for \(characteristic.uuid.uuidString): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) {
|
||||||
|
if let error = error {
|
||||||
|
log.warning("Failed to get RSSI: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastRSSI = RSSI.intValue
|
||||||
|
log.info("RSSI: \(lastRSSI)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||||
|
if let error = error {
|
||||||
|
log.error("Failed to read value update: \(error)")
|
||||||
|
continueRequest(with: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard case .configured(device: _, characteristic: let storedCharacteristic) = state else {
|
||||||
|
log.warning("Received data while not properly configured")
|
||||||
|
continueRequest(with: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard characteristic.uuid == storedCharacteristic.uuid else {
|
||||||
|
log.warning("Read unknown characteristic \(characteristic.uuid.uuidString)")
|
||||||
|
continueRequest(with: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let data = characteristic.value else {
|
||||||
|
log.warning("No data")
|
||||||
|
continueRequest(with: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continueRequest(with: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func continueRequest(with response: Data?) {
|
||||||
|
guard let requestContinuation else {
|
||||||
|
log.error("No continuation to handle request data (\(response?.count ?? 0) bytes)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestContinuation.resume(returning: response)
|
||||||
|
self.requestContinuation = nil
|
||||||
|
}
|
||||||
|
}
|
39
TempTrack/Connection/DeviceDataRequest.swift
Normal file
39
TempTrack/Connection/DeviceDataRequest.swift
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct DeviceDataRequest: DeviceRequest {
|
||||||
|
|
||||||
|
typealias Response = Data
|
||||||
|
|
||||||
|
static let type: BluetoothRequestType = .getRecordingData
|
||||||
|
|
||||||
|
let offset: Int
|
||||||
|
|
||||||
|
let count: Int
|
||||||
|
|
||||||
|
var payload: Data {
|
||||||
|
count.twoByteData + offset.twoByteData
|
||||||
|
}
|
||||||
|
|
||||||
|
init(offset: Int, count: Int) {
|
||||||
|
self.offset = offset
|
||||||
|
self.count = count
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeResponse(from responseData: Data, responseType: BluetoothResponseType) -> Data? {
|
||||||
|
switch responseType {
|
||||||
|
case .success:
|
||||||
|
break
|
||||||
|
case .responseTooLarge:
|
||||||
|
log.warning("Exceeded payload size for device data request (offset: \(offset), count: \(count))")
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
log.warning("Invalid response \(responseType) to device data request")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard responseData.count == count else {
|
||||||
|
log.warning("Got \(responseData.count) for device data request (offset: \(offset), count: \(count))")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return responseData
|
||||||
|
}
|
||||||
|
}
|
32
TempTrack/Connection/DeviceDataResetRequest.swift
Normal file
32
TempTrack/Connection/DeviceDataResetRequest.swift
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct DeviceDataResetRequest: DeviceRequest {
|
||||||
|
|
||||||
|
typealias Response = Void
|
||||||
|
|
||||||
|
static var type: BluetoothRequestType { .clearRecordingBuffer }
|
||||||
|
|
||||||
|
var payload: Data {
|
||||||
|
byteCount.twoByteData
|
||||||
|
}
|
||||||
|
|
||||||
|
let byteCount: Int
|
||||||
|
|
||||||
|
init(byteCount: Int) {
|
||||||
|
self.byteCount = byteCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeResponse(from responseData: Data, responseType: BluetoothResponseType) -> Void? {
|
||||||
|
switch responseType {
|
||||||
|
case .success:
|
||||||
|
return ()
|
||||||
|
case .invalidNumberOfBytesToDelete:
|
||||||
|
log.error("Device data reset failed: Invalid number of bytes")
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
log.warning("Invalid response \(responseType) to device data reset request")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
23
TempTrack/Connection/DeviceInfoRequest.swift
Normal file
23
TempTrack/Connection/DeviceInfoRequest.swift
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct DeviceInfoRequest: DeviceRequest {
|
||||||
|
|
||||||
|
typealias Response = DeviceInfo
|
||||||
|
|
||||||
|
static let type: BluetoothRequestType = .getInfo
|
||||||
|
|
||||||
|
let payload = Data()
|
||||||
|
|
||||||
|
func makeResponse(from responseData: Data, responseType: BluetoothResponseType) -> DeviceInfo? {
|
||||||
|
guard responseType == .success else {
|
||||||
|
log.warning("Invalid response \(responseType) to device info request")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try .init(info: responseData)
|
||||||
|
} catch {
|
||||||
|
log.error("Failed to decode device info: \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
17
TempTrack/Connection/DeviceRequest.swift
Normal file
17
TempTrack/Connection/DeviceRequest.swift
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol DeviceRequest {
|
||||||
|
|
||||||
|
associatedtype Response
|
||||||
|
|
||||||
|
static var type: BluetoothRequestType { get }
|
||||||
|
|
||||||
|
var payload: Data { get }
|
||||||
|
|
||||||
|
func makeResponse(from responseData: Data, responseType: BluetoothResponseType) -> Response?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DeviceRequest {
|
||||||
|
|
||||||
|
var type: BluetoothRequestType { Self.type }
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user