Transfer view, change data flow, actors

This commit is contained in:
Christoph Hagen
2023-07-02 17:29:39 +02:00
parent 8b4c4800c9
commit 396571fd30
24 changed files with 1285 additions and 302 deletions

View 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
}
}

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

View File

@ -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
}
}
*/