first commit

This commit is contained in:
fototeddy
2026-09-04 21:30:51 +02:00
commit da64b1b6bd
160 changed files with 20664 additions and 0 deletions
+294
View File
@@ -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()
}
}
}
+97
View File
@@ -0,0 +1,97 @@
import Foundation
/// Neigungsmesser VanAlign Pro ein ESP32 mit MPU6050, der Längs- und
/// Querneigung des Fahrzeugs über Bluetooth bereitstellt.
///
/// Anders als die übrigen Geräte gibt es hier kein Rahmenprotokoll: Jede
/// Messgrösse liegt in einer eigenen Charakteristik als 32-Bit-Float.
enum VanAlignProtocol {
/// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
static let serviceUUID = "2A24B789-7AAB-4535-AF3E-EE76A35CC42D"
static let pitchUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233424"
static let rollUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233425"
/// Zwei Floats: die gespeicherten Kalibrier-Offsets.
static let offsetsUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233426"
/// Ein Byte: 0 setzt zurück, alles andere kalibriert auf die aktuelle Lage.
static let calibrateUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233427"
/// Acht Byte: die Einbaulage, lesbar und schreibbar. Siehe `orientation`.
static let orientationUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233428"
static let calibrateCommand = Data([0x01])
static let resetCommand = Data([0x00])
/// Liest einen Winkel aus vier Bytes, little-endian.
///
/// Die Firmware legt den Float per `memcpy` ab, und der ESP32 ist
/// little-endian die Reihenfolge steht also fest. Die Web-Oberfläche des
/// Ursprungsprojekts probiert zusätzlich die umgekehrte Reihenfolge, falls
/// die erste unplausibel aussieht. Das ist nicht nur unnötig, sondern
/// schädlich: ein vertauschter Float von 4,25° ergibt gelesen etwa 0,0 und
/// wirkt damit völlig plausibel. Ein Vorzeichen- oder Wertfehler bliebe so
/// unbemerkt.
static func angle(from data: Data) -> Double? {
guard data.count >= 4 else { return nil }
var raw: UInt32 = 0
for (index, byte) in data.prefix(4).enumerated() {
raw |= UInt32(byte) << UInt32(8 * index)
}
let value = Float(bitPattern: raw)
// NAN meldet die Firmware, solange der Sensor nichts liefert.
guard value.isFinite, abs(value) <= 180 else { return nil }
return Double(value)
}
/// Die Einbaulage, wie sie im Gerät liegt.
///
/// Acht Byte:
///
/// 0 Version, 1 = gültig gesetzt, 0 = nie geschrieben
/// 1 Längsachse: 0 = Pitch des Sensors, 1 = Roll des Sensors
/// 2 längs umgekehrt (0/1)
/// 3 quer umgekehrt (0/1)
/// 4..7 Verdrehung um die Hochachse, float32, Grad
///
/// Sie gehört ins Gerät, weil sie den Einbau beschreibt und nicht das
/// Telefon: iPhone, Uhr und Android sollen dieselbe sehen, ohne sie je
/// einzeln zu bestimmen. Gerechnet wird trotzdem in den Apps das Gerät
/// verwahrt sie nur, sonst rechnete ein älterer Client die Korrektur ein
/// zweites Mal ein.
///
/// Version 0 heisst hier stand noch nie etwas und ergibt nil; dann gilt,
/// was die App örtlich gespeichert hat, und sie schreibt es hinauf.
static func orientation(from data: Data) -> SensorOrientation? {
let bytes = [UInt8](data)
guard bytes.count >= 8, bytes[0] == 1 else { return nil }
var orientation = SensorOrientation()
orientation.longitudinalSource = bytes[1] == 1 ? .roll : .pitch
orientation.invertLongitudinal = bytes[2] != 0
orientation.invertLateral = bytes[3] != 0
guard let twist = angle(from: data.dropFirst(4).prefix(4)) else { return nil }
orientation.twist = twist
return orientation
}
/// Dieselben acht Byte in die andere Richtung.
static func encoded(_ orientation: SensorOrientation) -> Data {
var bytes = [UInt8](repeating: 0, count: 8)
bytes[0] = 1
bytes[1] = orientation.longitudinalSource == .roll ? 1 : 0
bytes[2] = orientation.invertLongitudinal ? 1 : 0
bytes[3] = orientation.invertLateral ? 1 : 0
let raw = Float(orientation.twist).bitPattern
for index in 0..<4 {
bytes[4 + index] = UInt8((raw >> UInt32(8 * index)) & 0xFF)
}
return Data(bytes)
}
/// Die beiden gespeicherten Offsets.
static func offsets(from data: Data) -> (pitch: Double, roll: Double)? {
guard data.count >= 8,
let pitch = angle(from: data.prefix(4)),
let roll = angle(from: data.dropFirst(4).prefix(4)) else { return nil }
return (pitch, roll)
}
}