Transfer view, change data flow, actors
This commit is contained in:
150
TempTrack/Connection/BluetoothDevice.swift
Normal file
150
TempTrack/Connection/BluetoothDevice.swift
Normal file
@ -0,0 +1,150 @@
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
|
||||
actor BluetoothDevice: NSObject, ObservableObject {
|
||||
|
||||
private let peripheral: CBPeripheral!
|
||||
|
||||
private let characteristic: CBCharacteristic!
|
||||
|
||||
@MainActor @Published
|
||||
var lastDeviceInfo: DeviceInfo?
|
||||
|
||||
@Published
|
||||
private(set) var lastRSSI: Int = 0
|
||||
|
||||
init(peripheral: CBPeripheral, characteristic: CBCharacteristic) {
|
||||
self.peripheral = peripheral
|
||||
self.characteristic = characteristic
|
||||
super.init()
|
||||
|
||||
peripheral.delegate = self
|
||||
}
|
||||
|
||||
override init() {
|
||||
self.peripheral = nil
|
||||
self.characteristic = nil
|
||||
super.init()
|
||||
}
|
||||
|
||||
private var requestContinuation: (id: Int, call: CheckedContinuation<Data?, Never>)?
|
||||
|
||||
func updateInfo() async {
|
||||
guard let info = await getInfo() else {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
lastDeviceInfo = info
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
let requestData = Data([request.type.rawValue]) + request.payload
|
||||
let responseData: Data? = await withCheckedContinuation { continuation in
|
||||
let id = Int.random(in: .min...Int.max)
|
||||
requestContinuation = (id, continuation)
|
||||
peripheral.writeValue(requestData, for: characteristic, type: .withResponse)
|
||||
peripheral.readValue(for: characteristic)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) { [weak self] in
|
||||
Task {
|
||||
await self?.checkTimeoutForCurrentRequest(request.type, id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private func checkTimeoutForCurrentRequest(_ type: BluetoothRequestType, id: Int) {
|
||||
guard let requestContinuation else { return }
|
||||
guard requestContinuation.id == id else { return }
|
||||
log.info("Timed out for request \(type)")
|
||||
requestContinuation.call.resume(returning: nil)
|
||||
self.requestContinuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension BluetoothDevice: CBPeripheralDelegate {
|
||||
|
||||
nonisolated
|
||||
func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) {
|
||||
|
||||
}
|
||||
|
||||
nonisolated
|
||||
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||
Task {
|
||||
await didUpdateValue(for: characteristic, of: peripheral, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func didUpdateValue(for characteristic: CBCharacteristic, of peripheral: CBPeripheral, error: Error?) {
|
||||
if let error = error {
|
||||
log.error("Failed to read value update: \(error)")
|
||||
continueRequest(with: nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard characteristic.uuid == self.characteristic.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.call.resume(returning: response)
|
||||
self.requestContinuation = nil
|
||||
}
|
||||
}
|
176
TempTrack/Connection/BluetoothScanner.swift
Normal file
176
TempTrack/Connection/BluetoothScanner.swift
Normal file
@ -0,0 +1,176 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import CoreBluetooth
|
||||
|
||||
final class BluetoothScanner: NSObject, CBCentralManagerDelegate, ObservableObject {
|
||||
|
||||
enum ConnectionState {
|
||||
case noDeviceFound
|
||||
case connecting
|
||||
case discoveringService
|
||||
case discoveringCharacteristic
|
||||
}
|
||||
|
||||
private let serviceUUID = CBUUID(string: "22071991-cccc-cccc-cccc-000000000001")
|
||||
|
||||
private let characteristicUUID = CBUUID(string: "22071991-cccc-cccc-cccc-000000000002")
|
||||
|
||||
private var manager: CBCentralManager! = nil
|
||||
|
||||
@Published
|
||||
var bluetoothIsAvailable = false
|
||||
|
||||
@Published
|
||||
var connectionState: ConnectionState
|
||||
|
||||
@Published
|
||||
var configuredDevice: BluetoothDevice?
|
||||
|
||||
private var connectingDevice: CBPeripheral?
|
||||
|
||||
var isScanningForDevices: Bool {
|
||||
get {
|
||||
manager.isScanning
|
||||
}
|
||||
set {
|
||||
if newValue {
|
||||
guard !manager.isScanning else {
|
||||
return
|
||||
}
|
||||
manager.scanForPeripherals(withServices: [serviceUUID])
|
||||
log.info("Scanner: Started scanning for devices")
|
||||
} else {
|
||||
guard manager.isScanning else {
|
||||
return
|
||||
}
|
||||
manager.stopScan()
|
||||
log.info("Scanner: Stopped scanning for devices")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override init() {
|
||||
connectionState = .noDeviceFound
|
||||
super.init()
|
||||
self.manager = CBCentralManager(delegate: self, queue: nil)
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
|
||||
guard connectionState == .noDeviceFound && configuredDevice == nil && connectingDevice == nil else {
|
||||
log.info("Scanner: Discovered additional device '\(peripheral.name ?? "No Name")'")
|
||||
return
|
||||
}
|
||||
log.info("Scanner: Connecting to discovered device '\(peripheral.name ?? "No Name")'")
|
||||
connectingDevice = peripheral
|
||||
manager.connect(peripheral)
|
||||
}
|
||||
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
switch central.state {
|
||||
case .poweredOff:
|
||||
break
|
||||
case .poweredOn:
|
||||
bluetoothIsAvailable = true
|
||||
return
|
||||
case .unsupported:
|
||||
log.info("Bluetooth state: Not supported")
|
||||
case .unknown:
|
||||
log.info("Bluetooth state: Unknown")
|
||||
case .resetting:
|
||||
log.info("Bluetooth state: Resetting")
|
||||
case .unauthorized:
|
||||
log.info("Bluetooth state: Not authorized")
|
||||
@unknown default:
|
||||
log.warning("Bluetooth state: Unknown (\(central.state))")
|
||||
}
|
||||
bluetoothIsAvailable = false
|
||||
// TODO: Disconnect devices?
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
log.info("Scanner: Connected to '\(peripheral.name ?? "No Name")'")
|
||||
|
||||
connectionState = .discoveringService
|
||||
peripheral.delegate = self
|
||||
peripheral.discoverServices([serviceUUID])
|
||||
connectingDevice = peripheral
|
||||
configuredDevice = nil
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||||
log.info("Scanner: Disconnected from '\(peripheral.name ?? "No Name")'")
|
||||
connectionState = .noDeviceFound
|
||||
configuredDevice = nil
|
||||
connectingDevice = nil
|
||||
// TODO: Check if peripheral matches the connected device(s)
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
||||
log.warning("Scanner: Failed to connect to device '\(peripheral.name ?? "No Name")' (\(error?.localizedDescription ?? "No error"))")
|
||||
isScanningForDevices = true
|
||||
connectionState = .noDeviceFound
|
||||
connectingDevice = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension BluetoothScanner: CBPeripheralDelegate {
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||
guard let services = peripheral.services, !services.isEmpty else {
|
||||
log.error("Connected device '\(peripheral.name ?? "No Name")': No services found")
|
||||
manager.cancelPeripheralConnection(peripheral)
|
||||
connectionState = .noDeviceFound
|
||||
connectingDevice = nil
|
||||
return
|
||||
}
|
||||
guard let service = services.first(where: { $0.uuid.uuidString == DeviceManager.serviceUUID.uuidString }) else {
|
||||
log.error("Connected device '\(peripheral.name ?? "No Name")': Required service not found: \(services.map { $0.uuid.uuidString})")
|
||||
manager.cancelPeripheralConnection(peripheral)
|
||||
connectionState = .noDeviceFound
|
||||
connectingDevice = nil
|
||||
return
|
||||
}
|
||||
peripheral.delegate = self
|
||||
peripheral.discoverCharacteristics([characteristicUUID], for: service)
|
||||
connectionState = .discoveringCharacteristic
|
||||
connectingDevice = peripheral
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||||
if let error = error {
|
||||
log.error("Failed to discover characteristics: \(error)")
|
||||
manager.cancelPeripheralConnection(peripheral)
|
||||
connectionState = .noDeviceFound
|
||||
connectingDevice = nil
|
||||
return
|
||||
}
|
||||
|
||||
guard let characteristics = service.characteristics, !characteristics.isEmpty else {
|
||||
log.error("Connected device '\(peripheral.name ?? "No Name")': No characteristics found")
|
||||
manager.cancelPeripheralConnection(peripheral)
|
||||
connectionState = .noDeviceFound
|
||||
connectingDevice = nil
|
||||
return
|
||||
}
|
||||
|
||||
var desiredCharacteristic: CBCharacteristic? = nil
|
||||
for characteristic in characteristics {
|
||||
guard characteristic.uuid == characteristicUUID else {
|
||||
log.warning("Connected device '\(peripheral.name ?? "No Name")': Unused characteristic \(characteristic.uuid.uuidString)")
|
||||
continue
|
||||
}
|
||||
desiredCharacteristic = characteristic
|
||||
}
|
||||
|
||||
connectionState = .noDeviceFound
|
||||
connectingDevice = nil
|
||||
|
||||
guard let desiredCharacteristic else {
|
||||
log.error("Connected device '\(peripheral.name ?? "No Name")': Characteristic not found")
|
||||
manager.cancelPeripheralConnection(peripheral)
|
||||
return
|
||||
}
|
||||
|
||||
configuredDevice = .init(peripheral: peripheral, characteristic: desiredCharacteristic)
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
|
||||
final class DeviceConnection: NSObject, CBCentralManagerDelegate, ObservableObject {
|
||||
/*
|
||||
actor DeviceConnection: NSObject, CBCentralManagerDelegate, ObservableObject {
|
||||
|
||||
static let serviceUUID = CBUUID(string: "22071991-cccc-cccc-cccc-000000000001")
|
||||
|
||||
@ -149,10 +149,9 @@ final class DeviceConnection: NSObject, CBCentralManagerDelegate, ObservableObje
|
||||
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
|
||||
Task {
|
||||
await self?.checkTimeoutForCurrentRequest(request.type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -183,7 +182,21 @@ final class DeviceConnection: NSObject, CBCentralManagerDelegate, ObservableObje
|
||||
return request.makeResponse(from: responseData.dropFirst(), responseType: responseType)
|
||||
}
|
||||
|
||||
private func checkTimeoutForCurrentRequest(_ type: BluetoothRequestType) {
|
||||
guard let requestContinuation else { return }
|
||||
log.info("Timed out for request \(type)")
|
||||
requestContinuation.resume(returning: nil)
|
||||
self.requestContinuation = nil
|
||||
}
|
||||
|
||||
nonisolated
|
||||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
|
||||
Task {
|
||||
await didDiscover(peripheral: peripheral)
|
||||
}
|
||||
}
|
||||
|
||||
private func didDiscover(peripheral: CBPeripheral) {
|
||||
guard shouldConnectIfPossible else {
|
||||
return
|
||||
}
|
||||
@ -193,8 +206,15 @@ final class DeviceConnection: NSObject, CBCentralManagerDelegate, ObservableObje
|
||||
state = .connecting(device: peripheral)
|
||||
}
|
||||
|
||||
nonisolated
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
switch central.state {
|
||||
Task {
|
||||
await didUpdate(state: central.state)
|
||||
}
|
||||
}
|
||||
|
||||
private func didUpdate(state newState: CBManagerState) {
|
||||
switch newState {
|
||||
case .poweredOff:
|
||||
state = .bluetoothDisabled
|
||||
case .poweredOn:
|
||||
@ -214,17 +234,31 @@ final class DeviceConnection: NSObject, CBCentralManagerDelegate, ObservableObje
|
||||
log.info("Bluetooth is not authorized")
|
||||
@unknown default:
|
||||
state = .bluetoothDisabled
|
||||
log.warning("Unknown state \(central.state)")
|
||||
log.warning("Unknown state \(newState)")
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
Task {
|
||||
await didConnect(to: peripheral)
|
||||
}
|
||||
}
|
||||
|
||||
private func didConnect(to peripheral: CBPeripheral) {
|
||||
log.info("Connected to " + peripheral.name!)
|
||||
peripheral.discoverServices([DeviceManager.serviceUUID])
|
||||
state = .discoveringServices(device: peripheral)
|
||||
}
|
||||
|
||||
nonisolated
|
||||
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||||
Task {
|
||||
await didDisconnect(from: peripheral, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func didDisconnect(from peripheral: CBPeripheral, error: Error?) {
|
||||
log.info("Disconnected from " + peripheral.name!)
|
||||
state = .disconnected
|
||||
// Attempt to reconnect
|
||||
@ -233,7 +267,14 @@ final class DeviceConnection: NSObject, CBCentralManagerDelegate, ObservableObje
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated
|
||||
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
||||
Task {
|
||||
await didFailToConnect(to: peripheral, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func didFailToConnect(to peripheral: CBPeripheral, error: Error?) {
|
||||
log.warning("Failed to connect device '\(peripheral.name ?? "NO_NAME")'")
|
||||
if let error = error {
|
||||
log.warning(error.localizedDescription)
|
||||
@ -248,7 +289,14 @@ final class DeviceConnection: NSObject, CBCentralManagerDelegate, ObservableObje
|
||||
|
||||
extension DeviceConnection: CBPeripheralDelegate {
|
||||
|
||||
nonisolated
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||
Task {
|
||||
await didDiscoverServices(for: peripheral)
|
||||
}
|
||||
}
|
||||
|
||||
private func didDiscoverServices(for peripheral: CBPeripheral) {
|
||||
guard let services = peripheral.services, !services.isEmpty else {
|
||||
log.error("No services found for device '\(peripheral.name ?? "NO_NAME")'")
|
||||
manager.cancelPeripheralConnection(peripheral)
|
||||
@ -263,7 +311,14 @@ extension DeviceConnection: CBPeripheralDelegate {
|
||||
state = .discoveringCharacteristic(device: peripheral)
|
||||
}
|
||||
|
||||
nonisolated
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||||
Task {
|
||||
await didDiscoverCharacteristics(for: service, of: peripheral, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func didDiscoverCharacteristics(for service: CBService, of peripheral: CBPeripheral, error: Error?) {
|
||||
if let error = error {
|
||||
log.error("Failed to discover characteristics: \(error)")
|
||||
manager.cancelPeripheralConnection(peripheral)
|
||||
@ -284,22 +339,37 @@ extension DeviceConnection: CBPeripheralDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated
|
||||
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)")
|
||||
Task {
|
||||
await update(rssi: RSSI.intValue)
|
||||
}
|
||||
log.info("RSSI: \(RSSI.intValue)")
|
||||
}
|
||||
|
||||
private func update(rssi: Int) {
|
||||
lastRSSI = rssi
|
||||
}
|
||||
|
||||
nonisolated
|
||||
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||
Task {
|
||||
await didUpdateValue(for: characteristic, of: peripheral, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func didUpdateValue(for characteristic: CBCharacteristic, of peripheral: CBPeripheral, error: Error?) {
|
||||
if let error = error {
|
||||
log.error("Failed to read value update: \(error)")
|
||||
continueRequest(with: nil)
|
||||
@ -332,3 +402,4 @@ extension DeviceConnection: CBPeripheralDelegate {
|
||||
self.requestContinuation = nil
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
Reference in New Issue
Block a user