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" 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 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) } } /// Zustand des Neigungsmessers. struct LevelState: Equatable { /// Längsneigung: positiv bedeutet, das Heck steht höher als die Front. var pitch: Double? /// Querneigung: positiv bedeutet, die rechte Seite steht höher. var roll: Double? var pitchOffset: Double? var rollOffset: Double? var hasReading: Bool { pitch != nil || roll != nil } /// Ob kalibriert wurde – oder nil, wenn das Gerät es nicht verrät. /// /// Nicht jede Firmware stellt die Offsets bereit. Fehlen sie, heisst das /// „unbekannt“ und nicht „nicht kalibriert“: eine Warnung, die sich nie /// abstellen lässt, ist schlimmer als keine. var calibrationState: Bool? { guard let pitchOffset, let rollOffset else { return nil } return abs(pitchOffset) > 0.001 || abs(rollOffset) > 0.001 } /// Nur wahr, wenn es auch belegt ist. var isCalibrated: Bool { calibrationState == true } /// Nur wahr, wenn das Gerät ausdrücklich Nulloffsets meldet. var isKnownUncalibrated: Bool { calibrationState == false } /// Bis hierhin gilt das Fahrzeug als eben genug. static let levelTolerance = 0.5 var isLevel: Bool { guard let pitch, let roll else { return false } return abs(pitch) <= Self.levelTolerance && abs(roll) <= Self.levelTolerance } /// Die grössere der beiden Abweichungen – das ist die, die man zuerst /// ausgleichen will. var largestDeviation: Double? { switch (pitch, roll) { case let (p?, r?): return max(abs(p), abs(r)) case let (p?, nil): return abs(p) case let (nil, r?): return abs(r) default: return nil } } /// Was zu tun ist, in Worten. Beim Ausrichten schaut man aufs Handy, /// nicht auf Vorzeichen. var instruction: String? { guard let pitch, let roll else { return nil } if isLevel { return "Steht eben" } var parts: [String] = [] if abs(pitch) > Self.levelTolerance { parts.append(pitch > 0 ? "Heck steht höher" : "Front steht höher") } if abs(roll) > Self.levelTolerance { parts.append(roll > 0 ? "rechts steht höher" : "links steht höher") } return parts.joined(separator: ", ") } func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot { var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi) snapshot.metrics = [ Metric("pitch", "Längsneigung", pitch, unit: "°", precision: 1, primary: true), Metric("roll", "Querneigung", roll, unit: "°", precision: 1), ] snapshot.state = instruction if isKnownUncalibrated { snapshot.offReasons = ["Nicht kalibriert"] } return snapshot } }