first commit
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
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)
|
||||
private static let orientationUUID = CBUUID(string: VanAlignProtocol.orientationUUID)
|
||||
|
||||
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
|
||||
/// Meldet die Einbaulage, die im Gerät steht. Sie gilt vor der örtlich
|
||||
/// gespeicherten: dort steht, wie der Sensor eingebaut ist, und das ist für
|
||||
/// alle Clients dasselbe.
|
||||
private let onDeviceOrientation: ((SensorOrientation) -> Void)?
|
||||
|
||||
private var pitchCharacteristic: CBCharacteristic?
|
||||
private var rollCharacteristic: CBCharacteristic?
|
||||
private var offsetsCharacteristic: CBCharacteristic?
|
||||
private var calibrateCharacteristic: CBCharacteristic?
|
||||
private var orientationCharacteristic: 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,
|
||||
onDeviceOrientation: ((SensorOrientation) -> Void)? = nil) {
|
||||
self.onDeviceOrientation = onDeviceOrientation
|
||||
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
|
||||
orientationCharacteristic = nil
|
||||
}
|
||||
|
||||
// MARK: - Einbaulage im Gerät
|
||||
|
||||
/// Schreibt die Einbaulage ins Gerät, damit alle Clients dieselbe sehen.
|
||||
///
|
||||
/// Ältere Firmware hat die Charakteristik nicht; dann bleibt es bei der
|
||||
/// örtlich gespeicherten Fassung, und es passiert schlicht nichts.
|
||||
func storeOrientation(_ orientation: SensorOrientation) {
|
||||
guard let characteristic = orientationCharacteristic,
|
||||
peripheral.state == .connected else { return }
|
||||
let type: CBCharacteristicWriteType =
|
||||
characteristic.properties.contains(.write) ? .withResponse : .withoutResponse
|
||||
peripheral.writeValue(VanAlignProtocol.encoded(orientation),
|
||||
for: characteristic, type: type)
|
||||
// Zurücklesen, damit angezeigt wird, was wirklich im Gerät steht.
|
||||
queue.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||
guard let self, let characteristic = self.orientationCharacteristic,
|
||||
self.peripheral.state == .connected else { return }
|
||||
self.peripheral.readValue(for: characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
// 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, Self.orientationUUID],
|
||||
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
|
||||
case Self.orientationUUID: orientationCharacteristic = 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, orientationCharacteristic] {
|
||||
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
|
||||
}
|
||||
case Self.orientationUUID:
|
||||
// Was im Gerät steht, gilt: es beschreibt den Einbau, nicht das
|
||||
// Telefon. Steht dort nichts (Version 0), bleibt es bei der
|
||||
// örtlichen Fassung – der Aufrufer schreibt sie dann hinauf.
|
||||
guard let stored = VanAlignProtocol.orientation(from: value) else { return }
|
||||
if stored != orientation {
|
||||
orientation = stored
|
||||
}
|
||||
onDeviceOrientation?(stored)
|
||||
return
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user