import CoreBluetooth import Foundation /// Hält die Verbindung zum Neigungsmesser. /// /// Deutlich einfacher als die BMS-Sitzung: Dienst und Charakteristiken sind /// bekannt, es gibt kein Rahmenprotokoll und keine Protokollerkennung. Jede /// Messgrösse liegt in einer eigenen Charakteristik. /// /// Bevorzugt werden die Werte abonniert – die Firmware ab v1.1 schickt sie /// dann von selbst, sobald sie sich ändern. Ältere Firmware bietet nur /// Lesen an; dafür bleibt das Abfragen im Takt als Rückfallebene, damit die /// App auch ohne Neuaufspielen funktioniert. final class LevelSession: NSObject { static let serviceUUID = CBUUID(string: VanAlignProtocol.serviceUUID) private static let pitchUUID = CBUUID(string: VanAlignProtocol.pitchUUID) private static let rollUUID = CBUUID(string: VanAlignProtocol.rollUUID) private static let offsetsUUID = CBUUID(string: VanAlignProtocol.offsetsUUID) private static let calibrateUUID = CBUUID(string: VanAlignProtocol.calibrateUUID) let deviceID: UUID private let queue: DispatchQueue private let peripheral: CBPeripheral private let onUpdate: (DeviceSnapshot) -> Void private let onStateChange: (DeviceLinkState) -> Void private let onLevelState: (LevelState) -> Void private var pitchCharacteristic: CBCharacteristic? private var rollCharacteristic: CBCharacteristic? private var offsetsCharacteristic: CBCharacteristic? private var calibrateCharacteristic: CBCharacteristic? private var state = LevelState() /// Aus den Geräteeinstellungen; rechnet Sensor- in Fahrzeugachsen um. var orientation = SensorOrientation.identity { didSet { applyOrientation() } } private var pollTimer: DispatchSourceTimer? /// Nur nötig, solange das Gerät die Werte nicht von selbst schickt. private var needsPolling = false /// Beim Ausrichten schaut man dauernd aufs Gerät, deshalb dichter als bei /// den übrigen Geräten. var pollInterval: TimeInterval = 0.5 init(deviceID: UUID, peripheral: CBPeripheral, queue: DispatchQueue, onUpdate: @escaping (DeviceSnapshot) -> Void, onStateChange: @escaping (DeviceLinkState) -> Void, onLevelState: @escaping (LevelState) -> Void) { self.deviceID = deviceID self.queue = queue self.peripheral = peripheral self.onUpdate = onUpdate self.onStateChange = onStateChange self.onLevelState = onLevelState super.init() peripheral.delegate = self } // MARK: - Lebenszyklus func start() { onStateChange(.connecting) peripheral.discoverServices([Self.serviceUUID]) } func stop() { pollTimer?.cancel() pollTimer = nil if peripheral.state == .connected { for characteristic in [pitchCharacteristic, rollCharacteristic] { guard let characteristic, characteristic.isNotifying else { continue } peripheral.setNotifyValue(false, for: characteristic) } } pitchCharacteristic = nil rollCharacteristic = nil offsetsCharacteristic = nil calibrateCharacteristic = nil } func handleDisconnect() { pollTimer?.cancel() pollTimer = nil pitchCharacteristic = nil rollCharacteristic = nil offsetsCharacteristic = nil calibrateCharacteristic = nil } // MARK: - Kalibrieren /// Setzt die aktuelle Lage als neue Null. func calibrate() { write(VanAlignProtocol.calibrateCommand) } /// Verwirft die Kalibrierung. func resetCalibration() { write(VanAlignProtocol.resetCommand) } private func write(_ data: Data) { guard let characteristic = calibrateCharacteristic, peripheral.state == .connected else { return } let type: CBCharacteristicWriteType = characteristic.properties.contains(.write) ? .withResponse : .withoutResponse peripheral.writeValue(data, for: characteristic, type: type) // Die Offsets erst nach dem Rechnen im Gerät zurücklesen. queue.asyncAfter(deadline: .now() + 0.4) { [weak self] in self?.readOffsets() } } private func readOffsets() { guard let offsetsCharacteristic, peripheral.state == .connected else { return } peripheral.readValue(for: offsetsCharacteristic) } // MARK: - Abfrage private func startPollingIfNeeded() { guard needsPolling, pollTimer == nil else { return } let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now(), repeating: pollInterval) timer.setEventHandler { [weak self] in guard let self, self.peripheral.state == .connected else { return } if let pitchCharacteristic = self.pitchCharacteristic { self.peripheral.readValue(for: pitchCharacteristic) } if let rollCharacteristic = self.rollCharacteristic { self.peripheral.readValue(for: rollCharacteristic) } } timer.resume() pollTimer = timer } /// Rechnet die Rohwerte in Fahrzeugachsen um. private func applyOrientation() { let corrected = orientation.apply(pitch: state.rawPitch, roll: state.rawRoll) state.pitch = corrected.pitch state.roll = corrected.roll } private func publish() { guard state.hasReading else { return } onStateChange(.live) onLevelState(state) onUpdate(state.snapshot(deviceID: deviceID, rssi: nil)) } } // MARK: - CBPeripheralDelegate extension LevelSession: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { if let error { onStateChange(.failed(error.localizedDescription)) return } guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else { onStateChange(.failed("Neigungsmesser-Dienst nicht gefunden")) return } peripheral.discoverCharacteristics( [Self.pitchUUID, Self.rollUUID, Self.offsetsUUID, Self.calibrateUUID], for: service ) } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { guard error == nil, let characteristics = service.characteristics else { onStateChange(.failed(error?.localizedDescription ?? "Keine Merkmale gefunden")) return } for characteristic in characteristics { switch characteristic.uuid { case Self.pitchUUID: pitchCharacteristic = characteristic case Self.rollUUID: rollCharacteristic = characteristic case Self.offsetsUUID: offsetsCharacteristic = characteristic case Self.calibrateUUID: calibrateCharacteristic = characteristic default: break } } guard pitchCharacteristic != nil || rollCharacteristic != nil else { onStateChange(.failed("Neigungswerte nicht gefunden")) return } // Abonnieren, wo möglich; sonst im Takt abfragen. var subscribed = false for characteristic in [pitchCharacteristic, rollCharacteristic] { guard let characteristic else { continue } if characteristic.properties.contains(.notify) { peripheral.setNotifyValue(true, for: characteristic) subscribed = true } } needsPolling = !subscribed startPollingIfNeeded() // Einmal alles lesen, damit sofort etwas dasteht. for characteristic in [pitchCharacteristic, rollCharacteristic, offsetsCharacteristic] { guard let characteristic, characteristic.properties.contains(.read) else { continue } peripheral.readValue(for: characteristic) } } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { guard error == nil, let value = characteristic.value else { return } switch characteristic.uuid { case Self.pitchUUID: state.rawPitch = VanAlignProtocol.angle(from: value) applyOrientation() case Self.rollUUID: state.rawRoll = VanAlignProtocol.angle(from: value) applyOrientation() case Self.offsetsUUID: if let offsets = VanAlignProtocol.offsets(from: value) { state.pitchOffset = offsets.pitch state.rollOffset = offsets.roll } default: return } publish() } func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { // Lässt sich nicht abonnieren, dann eben abfragen. if error != nil { needsPolling = true startPollingIfNeeded() } } }