first commit
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import Foundation
|
||||
|
||||
/// Hilft beim Ausrichten des Fahrzeugs während des Rangierens.
|
||||
///
|
||||
/// Bewusst **ohne** Positionsbestimmung: Aus einem MEMS-Beschleunigungssensor
|
||||
/// lässt sich keine brauchbare Strecke ableiten, weil der Fehler beim
|
||||
/// zweifachen Integrieren quadratisch mit der Zeit wächst. Beim Rangieren im
|
||||
/// Schritttempo gehen die tatsächlichen Beschleunigungen ohnehin im Rauschen
|
||||
/// unter.
|
||||
///
|
||||
/// Gebraucht wird das auch gar nicht. Die Frage beim Einparken lautet nie „wo
|
||||
/// stehe ich“, sondern „wird es besser oder schlechter, und wo war es am
|
||||
/// besten“. Beides steckt bereits im zeitlichen Verlauf der Neigung – ganz
|
||||
/// ohne Annahmen über das Gelände.
|
||||
struct AlignmentAssistant {
|
||||
|
||||
struct Sample: Equatable {
|
||||
let time: Date
|
||||
let pitch: Double
|
||||
let roll: Double
|
||||
|
||||
/// Gesamtabweichung von der Waagerechten.
|
||||
var deviation: Double { (pitch * pitch + roll * roll).squareRoot() }
|
||||
}
|
||||
|
||||
/// Wie lange zurückgeschaut wird.
|
||||
static let memory: TimeInterval = 90
|
||||
/// Ab dieser Verbesserung lohnt der Hinweis auf einen früheren Punkt.
|
||||
static let worthGoingBack = 0.2
|
||||
/// Unterhalb dieser Änderung gilt die Lage als unverändert.
|
||||
static let trendThreshold = 0.08
|
||||
|
||||
private(set) var samples: [Sample] = []
|
||||
|
||||
enum Trend: Equatable {
|
||||
case improving
|
||||
case worsening
|
||||
case steady
|
||||
case unknown
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .improving: return "Wird besser"
|
||||
case .worsening: return "Wird schlechter"
|
||||
case .steady: return "Bleibt gleich"
|
||||
case .unknown: return "Messe…"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .improving: return "arrow.down.right.circle.fill"
|
||||
case .worsening: return "arrow.up.right.circle.fill"
|
||||
case .steady: return "equal.circle.fill"
|
||||
case .unknown: return "hourglass.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutating func add(pitch: Double, roll: Double, at time: Date = Date()) {
|
||||
samples.append(Sample(time: time, pitch: pitch, roll: roll))
|
||||
let cutoff = time.addingTimeInterval(-Self.memory)
|
||||
samples.removeAll { $0.time < cutoff }
|
||||
}
|
||||
|
||||
mutating func reset() {
|
||||
samples.removeAll()
|
||||
}
|
||||
|
||||
var current: Sample? { samples.last }
|
||||
|
||||
/// Der flachste Punkt, den wir gesehen haben.
|
||||
var best: Sample? {
|
||||
samples.min { $0.deviation < $1.deviation }
|
||||
}
|
||||
|
||||
/// Ob und wie stark sich die Lage gerade ändert.
|
||||
///
|
||||
/// Verglichen wird das jüngste Drittel mit dem davorliegenden. Einzelne
|
||||
/// Messwerte wären zu unruhig; das Fahrzeug wippt beim Rangieren.
|
||||
var trend: Trend {
|
||||
guard samples.count >= 6 else { return .unknown }
|
||||
let recent = samples.suffix(3)
|
||||
let previous = samples.dropLast(3).suffix(3)
|
||||
guard !previous.isEmpty else { return .unknown }
|
||||
|
||||
let now = recent.map(\.deviation).reduce(0, +) / Double(recent.count)
|
||||
let before = previous.map(\.deviation).reduce(0, +) / Double(previous.count)
|
||||
let change = now - before
|
||||
|
||||
if abs(change) < Self.trendThreshold { return .steady }
|
||||
return change < 0 ? .improving : .worsening
|
||||
}
|
||||
|
||||
/// Wieviel besser der beste Punkt gegenüber jetzt war – nil, wenn es sich
|
||||
/// nicht lohnt oder wir gerade selbst am besten Punkt stehen.
|
||||
var improvementAtBest: Double? {
|
||||
guard let current, let best, best.time < current.time else { return nil }
|
||||
let gain = current.deviation - best.deviation
|
||||
return gain >= Self.worthGoingBack ? gain : nil
|
||||
}
|
||||
|
||||
/// Wie lange der beste Punkt zurückliegt.
|
||||
var timeSinceBest: TimeInterval? {
|
||||
guard improvementAtBest != nil, let current, let best else { return nil }
|
||||
return current.time.timeIntervalSince(best.time)
|
||||
}
|
||||
|
||||
/// Was der Fahrer jetzt tun soll.
|
||||
var advice: String {
|
||||
guard let current else { return "Warte auf Messwerte…" }
|
||||
if current.deviation <= LevelState.levelTolerance {
|
||||
return "Steht eben – anhalten"
|
||||
}
|
||||
if let seconds = timeSinceBest {
|
||||
return String(format: "Vor %.0f s stand es besser – ein Stück zurück",
|
||||
seconds.rounded())
|
||||
}
|
||||
switch trend {
|
||||
case .improving: return "Wird besser – weiter so"
|
||||
case .worsening: return "Wird schlechter – andere Richtung"
|
||||
case .steady: return "Ändert sich kaum – andere Richtung versuchen"
|
||||
case .unknown: return "Langsam weiterfahren"
|
||||
}
|
||||
}
|
||||
|
||||
var hasReachedTarget: Bool {
|
||||
(current?.deviation ?? .infinity) <= LevelState.levelTolerance
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie hoch jedes einzelne Rad unterlegt werden muss.
|
||||
///
|
||||
/// Getrennte Angaben wie „rechts 8 cm" und „vorne 4 cm" beschreiben dasselbe
|
||||
/// Fahrzeug und lassen sich nicht getrennt ausführen: Unter „rechts" liegen
|
||||
/// zwei Räder, unter „vorne" auch, und zwei davon sind dieselben. Gefragt ist
|
||||
/// deshalb die Höhe **je Rad**.
|
||||
///
|
||||
/// Das ist reine Geometrie und damit exakt. Das Fahrzeug steht auf vier
|
||||
/// Punkten; deren Höhen ergeben sich aus Spurweite, Radstand und den beiden
|
||||
/// Winkeln. Angehoben wird auf die Höhe des höchsten Rades – das bleibt
|
||||
/// liegen, die anderen bekommen die Differenz.
|
||||
struct LevelingLift: Equatable {
|
||||
|
||||
enum Wheel: CaseIterable, Hashable {
|
||||
case frontLeft, frontRight, rearLeft, rearRight
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .frontLeft: return "vorne links"
|
||||
case .frontRight: return "vorne rechts"
|
||||
case .rearLeft: return "hinten links"
|
||||
case .rearRight: return "hinten rechts"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Höhen in Metern, je Rad. Das höchste Rad steht auf 0.
|
||||
let frontLeft: Double
|
||||
let frontRight: Double
|
||||
let rearLeft: Double
|
||||
let rearRight: Double
|
||||
|
||||
func height(_ wheel: Wheel) -> Double {
|
||||
switch wheel {
|
||||
case .frontLeft: return frontLeft
|
||||
case .frontRight: return frontRight
|
||||
case .rearLeft: return rearLeft
|
||||
case .rearRight: return rearRight
|
||||
}
|
||||
}
|
||||
|
||||
func centimetres(_ wheel: Wheel) -> Double { height(wheel) * 100 }
|
||||
|
||||
/// Das am tiefsten stehende Rad – dort liegt die grösste Höhe.
|
||||
var deepest: Wheel {
|
||||
Wheel.allCases.max { height($0) < height($1) } ?? .frontLeft
|
||||
}
|
||||
|
||||
var maximumCentimetres: Double { centimetres(deepest) }
|
||||
|
||||
/// Unter einem Zentimeter lohnt kein Keil; so genau steht kein Fahrzeug,
|
||||
/// und so genau misst der Sensor auch nicht.
|
||||
var isNegligible: Bool { maximumCentimetres < 1 }
|
||||
|
||||
/// `pitch` positiv heisst, das Heck steht höher; `roll` positiv, die rechte
|
||||
/// Seite steht höher. Ohne Fahrzeugmasse gibt es nichts zu rechnen.
|
||||
static func compute(pitch: Double,
|
||||
roll: Double,
|
||||
trackWidth: Double,
|
||||
wheelbase: Double) -> LevelingLift? {
|
||||
guard trackWidth > 0, wheelbase > 0 else { return nil }
|
||||
|
||||
// Wie viel höher die rechte Seite und wie viel höher das Heck steht.
|
||||
// Vorzeichen inbegriffen: tan(-2°) ist negativ, dann steht links höher.
|
||||
let rightRise = tan(roll * .pi / 180) * trackWidth
|
||||
let rearRise = tan(pitch * .pi / 180) * wheelbase
|
||||
|
||||
// Höhen der vier Aufstandspunkte, bezogen auf vorne links.
|
||||
let heights: [Wheel: Double] = [
|
||||
.frontLeft: 0,
|
||||
.frontRight: rightRise,
|
||||
.rearLeft: rearRise,
|
||||
.rearRight: rightRise + rearRise,
|
||||
]
|
||||
let top = heights.values.max() ?? 0
|
||||
|
||||
return LevelingLift(frontLeft: top - heights[.frontLeft]!,
|
||||
frontRight: top - heights[.frontRight]!,
|
||||
rearLeft: top - heights[.rearLeft]!,
|
||||
rearRight: top - heights[.rearRight]!)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import Foundation
|
||||
|
||||
/// Welche Rolle ein Gerät im Camper spielt. Bestimmt Icon, Sortierung und
|
||||
/// welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird.
|
||||
enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
//case chargeBooster
|
||||
//case solarCharger
|
||||
//case batteryMonitor
|
||||
//case bms
|
||||
//case fridge
|
||||
case leveling
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
//case .chargeBooster: return "Ladebooster"
|
||||
//case .solarCharger: return "Solarladeregler"
|
||||
//case .batteryMonitor: return "Batteriemonitor"
|
||||
//case .bms: return "Batterie / BMS"
|
||||
//case .fridge: return "Kühlbox"
|
||||
case .leveling: return "Nivellierung"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
//case .chargeBooster: return "bolt.car"
|
||||
//case .solarCharger: return "sun.max"
|
||||
//case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent"
|
||||
//case .bms: return "battery.100percent.bolt"
|
||||
//case .fridge: return "refrigerator"
|
||||
case .leveling: return "level"
|
||||
}
|
||||
}
|
||||
|
||||
/// Es gibt keine Geräte mehr, die nur auf Anforderung verbunden werden –
|
||||
/// der Neigungsmesser bleibt immer verbunden.
|
||||
var connectsOnDemand: Bool { false }
|
||||
/*/// Ob nur verbunden wird, während die Ansicht des Geräts offen ist.
|
||||
///
|
||||
/// Die Kühlbox meldet jede Verbindung an ihrem Display an und verlangt je
|
||||
/// nach Modell sogar einen Tastendruck; dauerhaft verbunden zu sein ist
|
||||
/// dort also nicht unsichtbar, sondern lästig. Ihre Werte ändern sich
|
||||
/// ausserdem langsam, und was auf der Übersicht zählt – der Sollwert –
|
||||
/// ändert sich überhaupt nur, wenn jemand ihn ändert.
|
||||
///
|
||||
/// BMS und Neigungsmesser bleiben dagegen verbunden: Sie stören nicht, und
|
||||
/// ihre Werte will man laufend sehen.
|
||||
var connectsOnDemand: Bool { self == .fridge }*/
|
||||
|
||||
/// Der Neigungsmesser braucht eine echte GATT-Verbindung, um Werte zu liefern.
|
||||
var transport: DeviceTransport {
|
||||
return .connect
|
||||
}
|
||||
/*/// Victron-Geräte werden passiv über das Advertisement gelesen, das Daly BMS
|
||||
/// braucht eine echte GATT-Verbindung.
|
||||
var transport: DeviceTransport {
|
||||
switch self {
|
||||
case .bms, .fridge, .leveling: return .connect
|
||||
default: return .advertisement
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
enum DeviceTransport: Sendable {
|
||||
/// Passives Mitlesen der BLE-Werbedaten (Victron Instant Readout).
|
||||
case advertisement
|
||||
/// Verbindungsaufbau, Kommando schreiben, Antwort per Notify lesen (Daly).
|
||||
case connect
|
||||
}
|
||||
|
||||
/*/// Wie viele Kühlzonen eine Box hat. Manche Einzonen-Boxen senden den langen
|
||||
/// Datensatz einer Doppelzonen-Box mit, weshalb die Erkennung übersteuerbar ist.
|
||||
enum FridgeZoneMode: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case automatic
|
||||
case single
|
||||
case dual
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .automatic: return "Automatisch"
|
||||
case .single: return "Eine Zone"
|
||||
case .dual: return "Zwei Zonen"
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
/// Ein vom Nutzer eingerichtetes Gerät. Der Victron-Schlüssel liegt nicht hier,
|
||||
/// sondern in der Keychain (siehe `KeychainStore`).
|
||||
struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var role: DeviceRole
|
||||
/// Zu welchem Fahrzeug das Gerät gehört.
|
||||
var profileID: UUID
|
||||
/// CoreBluetooth-Identifier des Peripherals. Pro iPhone stabil, aber nicht
|
||||
/// geräteübergreifend – deshalb wird beim Einrichten neu gescannt.
|
||||
var peripheralID: UUID
|
||||
/// Zuletzt gesehener Advertised Name, nur zur Wiedererkennung in der UI.
|
||||
var advertisedName: String?
|
||||
/// Nur für Kühlboxen.
|
||||
//var fridgeZoneMode: FridgeZoneMode = .automatic
|
||||
/// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt.
|
||||
var sensorOrientation = SensorOrientation.identity
|
||||
|
||||
init(id: UUID = UUID(),
|
||||
name: String,
|
||||
role: DeviceRole,
|
||||
profileID: UUID,
|
||||
peripheralID: UUID,
|
||||
advertisedName: String? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.role = role
|
||||
self.profileID = profileID
|
||||
self.peripheralID = peripheralID
|
||||
self.advertisedName = advertisedName
|
||||
}
|
||||
|
||||
/// Geräte, die vor der Profilverwaltung angelegt wurden, haben noch keine
|
||||
/// Zuordnung – die landen im Standardprofil.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
role = try container.decode(DeviceRole.self, forKey: .role)
|
||||
profileID = try container.decodeIfPresent(UUID.self, forKey: .profileID)
|
||||
?? Profile.defaultID
|
||||
peripheralID = try container.decode(UUID.self, forKey: .peripheralID)
|
||||
advertisedName = try container.decodeIfPresent(String.self, forKey: .advertisedName)
|
||||
//fridgeZoneMode = try container.decodeIfPresent(FridgeZoneMode.self, forKey: .fridgeZoneMode)
|
||||
// ?? .automatic
|
||||
sensorOrientation = try container.decodeIfPresent(SensorOrientation.self,
|
||||
forKey: .sensorOrientation) ?? .identity
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
|
||||
/// Eine einzelne Messgrösse in einer bereits formatierten Form.
|
||||
struct Metric: Identifiable, Hashable, Codable, Sendable {
|
||||
let key: String
|
||||
let label: String
|
||||
let value: Double?
|
||||
let unit: String
|
||||
/// Nachkommastellen für die Anzeige.
|
||||
let precision: Int
|
||||
/// Wird auf der Kachel gross dargestellt.
|
||||
let isPrimary: Bool
|
||||
|
||||
var id: String { key }
|
||||
|
||||
init(_ key: String,
|
||||
_ label: String,
|
||||
_ value: Double?,
|
||||
unit: String,
|
||||
precision: Int = 2,
|
||||
primary: Bool = false) {
|
||||
self.key = key
|
||||
self.label = label
|
||||
self.value = value
|
||||
self.unit = unit
|
||||
self.precision = precision
|
||||
self.isPrimary = primary
|
||||
}
|
||||
|
||||
var formatted: String {
|
||||
guard let value else { return "–" }
|
||||
return String(format: "%.\(precision)f", value)
|
||||
}
|
||||
|
||||
var formattedWithUnit: String {
|
||||
guard value != nil else { return "–" }
|
||||
return unit.isEmpty ? formatted : "\(formatted) \(unit)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Der komplette, zuletzt empfangene Zustand eines Geräts.
|
||||
struct DeviceSnapshot: Identifiable, Codable, Equatable, Sendable {
|
||||
var id: UUID { deviceID }
|
||||
var deviceID: UUID
|
||||
var timestamp: Date
|
||||
var metrics: [Metric] = []
|
||||
/// z.B. "Bulk", "Float", "Aus"
|
||||
var state: String?
|
||||
/// Klartext einer aktiven Störung, sonst nil.
|
||||
var fault: String?
|
||||
/// Grund, warum das Gerät gerade nicht lädt (Victron Off-Reason).
|
||||
var offReasons: [String] = []
|
||||
var rssi: Int?
|
||||
/// Einzelzellspannungen in Volt (nur BMS).
|
||||
var cellVoltages: [Double] = []
|
||||
/// Temperaturfühler in °C (nur BMS).
|
||||
var temperatures: [Double] = []
|
||||
/// Feste Angaben des Geräts, etwa Modell oder Seriennummer.
|
||||
var info: [InfoItem] = []
|
||||
|
||||
struct InfoItem: Identifiable, Hashable, Codable, Sendable {
|
||||
let label: String
|
||||
let value: String
|
||||
var id: String { label }
|
||||
}
|
||||
|
||||
var primaryMetric: Metric? {
|
||||
metrics.first(where: \.isPrimary) ?? metrics.first
|
||||
}
|
||||
|
||||
var age: TimeInterval { Date().timeIntervalSince(timestamp) }
|
||||
|
||||
/// Werte älter als eine Minute gelten als veraltet – Victron sendet etwa
|
||||
/// jede Sekunde, das Daly wird alle paar Sekunden gepollt.
|
||||
var isStale: Bool { age > 60 }
|
||||
}
|
||||
|
||||
/// Verbindungszustand für die UI.
|
||||
enum DeviceLinkState: Equatable, Codable, Sendable {
|
||||
case idle
|
||||
case searching
|
||||
case connecting
|
||||
case live
|
||||
case needsKey
|
||||
case failed(String)
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .idle: return "Inaktiv"
|
||||
case .searching: return "Suche…"
|
||||
case .connecting: return "Verbinde…"
|
||||
case .live: return "Live"
|
||||
case .needsKey: return "Schlüssel fehlt"
|
||||
case .failed(let m): return m
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
/// Zustand des Neigungsmessers.
|
||||
struct LevelState: Equatable, Codable, Sendable {
|
||||
/// Längsneigung: positiv bedeutet, das Heck steht höher als die Front.
|
||||
/// Bereits auf die Einbaulage des Sensors umgerechnet.
|
||||
var pitch: Double?
|
||||
/// Querneigung: positiv bedeutet, die rechte Seite steht höher.
|
||||
var roll: Double?
|
||||
/// Wie der Sensor selbst meldet – nötig, um die Einbaulage zu bestimmen.
|
||||
var rawPitch: Double?
|
||||
var rawRoll: 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
/// Ein Fahrzeug. Jedes Profil hat seinen eigenen Satz Geräte; die App zeigt und
|
||||
/// funkt immer nur für das gerade gewählte.
|
||||
struct Profile: Identifiable, Codable, Hashable, Sendable {
|
||||
var id: UUID
|
||||
var name: String
|
||||
/// SF-Symbol für die Auswahl im Dashboard.
|
||||
var symbol: String
|
||||
|
||||
/// Spurweite in Metern – für die Berechnung der Auffahrkeile quer.
|
||||
var trackWidth: Double?
|
||||
/// Radstand in Metern – dasselbe längs.
|
||||
var wheelbase: Double?
|
||||
|
||||
init(id: UUID = UUID(),
|
||||
name: String,
|
||||
symbol: String = "box.truck",
|
||||
trackWidth: Double? = nil,
|
||||
wheelbase: Double? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.symbol = symbol
|
||||
self.trackWidth = trackWidth
|
||||
self.wheelbase = wheelbase
|
||||
}
|
||||
|
||||
/// Profile aus der Zeit vor den Fahrzeugmassen haben noch keine.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
symbol = try container.decode(String.self, forKey: .symbol)
|
||||
trackWidth = try container.decodeIfPresent(Double.self, forKey: .trackWidth)
|
||||
wheelbase = try container.decodeIfPresent(Double.self, forKey: .wheelbase)
|
||||
}
|
||||
|
||||
/// Profil, dem Geräte aus der Zeit vor der Profilverwaltung zugeordnet
|
||||
/// werden. Feste Kennung, damit die Zuordnung beim Update erhalten bleibt.
|
||||
static let defaultID = UUID(uuidString: "00000000-0000-0000-0000-00000000C001")!
|
||||
|
||||
static var initial: Profile {
|
||||
Profile(id: defaultID, name: "Mein Camper")
|
||||
}
|
||||
|
||||
/// Auswahl für die Profilbearbeitung.
|
||||
/// Alle Namen gegen NSImage(systemSymbolName:) geprüft – ein nicht
|
||||
/// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen.
|
||||
static let symbols = [
|
||||
"box.truck", "truck.pickup.side", "bus", "bus.doubledecker",
|
||||
"car", "car.side", "tent", "sailboat", "house.lodge", "mountain.2",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import Foundation
|
||||
|
||||
/// Wie der Neigungsmesser im Fahrzeug sitzt.
|
||||
///
|
||||
/// Der Sensor kann quer eingebaut, gedreht oder kopfüber montiert sein. Dann
|
||||
/// stimmen seine Achsen nicht mit denen des Fahrzeugs überein: was er als
|
||||
/// Längsneigung meldet, ist womöglich die Querneigung, und Vorzeichen können
|
||||
/// vertauscht sein.
|
||||
///
|
||||
/// Statt das raten zu lassen, ermittelt der Einrichtungsassistent die Lage
|
||||
/// durch zwei definierte Kippbewegungen.
|
||||
struct SensorOrientation: Codable, Equatable, Hashable, Sendable {
|
||||
|
||||
/// Welche Achse des Sensors die Längsneigung des Fahrzeugs liefert.
|
||||
enum Source: String, Codable, Hashable, Sendable {
|
||||
case pitch
|
||||
case roll
|
||||
}
|
||||
|
||||
/// Woher die Längsneigung kommt. Die Querneigung kommt aus der jeweils
|
||||
/// anderen Achse.
|
||||
var longitudinalSource: Source = .pitch
|
||||
var invertLongitudinal = false
|
||||
var invertLateral = false
|
||||
|
||||
/// Verdrehung des Sensors um die Hochachse, in Grad – der Rest, den der
|
||||
/// Achsentausch nicht abdeckt.
|
||||
///
|
||||
/// Sitzt der Sensor schräg im Fahrzeug, verteilt sich eine reine
|
||||
/// Querneigung auf beide Sensorachsen: Das Fahrzeug kippt zur Seite, die
|
||||
/// Anzeige meldet zusätzlich Längsneigung. Achsentausch und Vorzeichen
|
||||
/// helfen dagegen nicht, die springen in 90°-Schritten.
|
||||
///
|
||||
/// Der Wert wird beim Bestimmen der Einbaulage mitgemessen und kostet
|
||||
/// keinen zusätzlichen Handgriff. Beim **Kalibrieren** liesse er sich
|
||||
/// nicht ermitteln: Das misst eine einzige Lage und zieht sie als Nullpunkt
|
||||
/// ab – eine Drehung um die Hochachse ist darin nicht enthalten, denn eben
|
||||
/// sieht in jeder Verdrehung gleich aus.
|
||||
var twist: Double = 0
|
||||
|
||||
static let identity = SensorOrientation()
|
||||
|
||||
init() {}
|
||||
|
||||
/// Von Hand geschrieben, nicht von Swift erzeugt – und das ist der Punkt.
|
||||
///
|
||||
/// Die erzeugte Fassung verlangt beim Lesen jedes Feld; Standardwerte im
|
||||
/// Code zählen dabei nicht. Eine Einbaulage, die vor `twist` gespeichert
|
||||
/// wurde, liesse sich damit nicht mehr lesen, das Gerät dazu ebenso wenig,
|
||||
/// und die Geräteliste bliebe leer. Jedes neue Feld gehört deshalb hier
|
||||
/// hinein, mit `decodeIfPresent` und einem Rückfallwert.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
longitudinalSource = try container.decodeIfPresent(Source.self,
|
||||
forKey: .longitudinalSource) ?? .pitch
|
||||
invertLongitudinal = try container.decodeIfPresent(Bool.self,
|
||||
forKey: .invertLongitudinal) ?? false
|
||||
invertLateral = try container.decodeIfPresent(Bool.self, forKey: .invertLateral) ?? false
|
||||
twist = try container.decodeIfPresent(Double.self, forKey: .twist) ?? 0
|
||||
}
|
||||
|
||||
var isIdentity: Bool { self == .identity }
|
||||
|
||||
/// Rechnet Sensorwerte in Fahrzeugwerte um.
|
||||
///
|
||||
/// Zwei Schritte, in dieser Reihenfolge: erst die grobe Zuordnung der
|
||||
/// Achsen samt Vorzeichen, dann die Verdrehung zurückdrehen. Für kleine
|
||||
/// Winkel verhält sich das Wertepaar wie ein Vektor in der Ebene – genau
|
||||
/// deshalb lässt sich die Verdrehung überhaupt herausrechnen.
|
||||
func apply(pitch: Double?, roll: Double?) -> (pitch: Double?, roll: Double?) {
|
||||
let longitudinal = longitudinalSource == .pitch ? pitch : roll
|
||||
let lateral = longitudinalSource == .pitch ? roll : pitch
|
||||
let mappedLongitudinal = longitudinal.map { invertLongitudinal ? -$0 : $0 }
|
||||
let mappedLateral = lateral.map { invertLateral ? -$0 : $0 }
|
||||
|
||||
guard twist != 0, let long = mappedLongitudinal, let lat = mappedLateral else {
|
||||
return (mappedLongitudinal, mappedLateral)
|
||||
}
|
||||
let angle = twist * .pi / 180
|
||||
return (long * cos(angle) + lat * sin(angle),
|
||||
-long * sin(angle) + lat * cos(angle))
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
if isIdentity { return "Achsen unverändert" }
|
||||
var parts: [String] = []
|
||||
if longitudinalSource == .roll { parts.append("Achsen getauscht") }
|
||||
if invertLongitudinal { parts.append("längs umgekehrt") }
|
||||
if invertLateral { parts.append("quer umgekehrt") }
|
||||
if twist != 0 { parts.append(String(format: "um %.0f° verdreht", twist)) }
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Wertet die Kippbewegungen des Einrichtungsassistenten aus.
|
||||
///
|
||||
/// Zweimal wird gekippt: einmal die Front nach unten, einmal die linke Seite
|
||||
/// nach unten. Welche Sensorachse sich dabei jeweils bewegt und in welche
|
||||
/// Richtung, ergibt die Einbaulage.
|
||||
enum OrientationDetection {
|
||||
|
||||
/// So weit muss gekippt werden, damit die Bewegung eindeutig ist.
|
||||
/// Darunter wäre nicht zu unterscheiden, ob überhaupt gekippt wurde.
|
||||
static let minimumTilt = 5.0
|
||||
|
||||
/// Soviel deutlicher muss die gewinnende Deutung sein als die andere.
|
||||
static let ambiguityMargin = 1.3
|
||||
|
||||
/// Ab hier gilt eine Verdrehung als echt und nicht als Wackeln der Hand.
|
||||
static let minimumTwist = 2.0
|
||||
|
||||
struct Reading: Equatable {
|
||||
let pitch: Double
|
||||
let roll: Double
|
||||
|
||||
static func - (lhs: Reading, rhs: Reading) -> Reading {
|
||||
Reading(pitch: lhs.pitch - rhs.pitch, roll: lhs.roll - rhs.roll)
|
||||
}
|
||||
}
|
||||
|
||||
enum Failure: Error, Equatable {
|
||||
/// Es wurde zu wenig oder gar nicht gekippt.
|
||||
case tooLittleMovement
|
||||
/// Beide Achsen haben sich ähnlich stark bewegt – dann lässt sich
|
||||
/// nicht sagen, welche gemeint war.
|
||||
case ambiguous
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .tooLittleMovement:
|
||||
return "Zu wenig Bewegung. Deutlicher kippen, mindestens eine "
|
||||
+ "Handbreit."
|
||||
case .ambiguous:
|
||||
return "Beide Schritte haben dieselbe Achse bewegt. Im ersten "
|
||||
+ "Schritt nach vorne kippen, im zweiten zur Seite."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ermittelt aus beiden Kippbewegungen die Einbaulage.
|
||||
///
|
||||
/// Beide Bewegungen werden gemeinsam beurteilt. Kippt man von Hand zur
|
||||
/// Seite, geht die andere Achse fast immer ein Stück mit – jede Bewegung
|
||||
/// für sich betrachtet sähe das nach schrägem Kippen aus. Im Paar ist die
|
||||
/// Zuordnung trotzdem eindeutig: es gewinnt die Deutung, die beide
|
||||
/// Bewegungen zusammen am besten erklärt.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - nose: Änderung beim Kippen der Front nach unten.
|
||||
/// - side: Änderung beim Kippen der linken Seite nach unten.
|
||||
static func orientation(nose: Reading,
|
||||
side: Reading) -> Result<SensorOrientation, Failure> {
|
||||
guard max(abs(nose.pitch), abs(nose.roll)) >= minimumTilt,
|
||||
max(abs(side.pitch), abs(side.roll)) >= minimumTilt else {
|
||||
return .failure(.tooLittleMovement)
|
||||
}
|
||||
|
||||
// Zwei mögliche Deutungen, bewertet danach, wieviel Bewegung sie
|
||||
// erklären: die erste Achse längs und die zweite quer – oder umgekehrt.
|
||||
let pitchIsLongitudinal = abs(nose.pitch) + abs(side.roll)
|
||||
let rollIsLongitudinal = abs(nose.roll) + abs(side.pitch)
|
||||
let winner = max(pitchIsLongitudinal, rollIsLongitudinal)
|
||||
let loser = min(pitchIsLongitudinal, rollIsLongitudinal)
|
||||
// Liegen beide Deutungen dicht beieinander, ist die Zuordnung wirklich
|
||||
// nicht zu treffen – etwa wenn zweimal um dieselbe Achse gekippt wurde.
|
||||
guard winner >= ambiguityMargin * loser else { return .failure(.ambiguous) }
|
||||
|
||||
var orientation = SensorOrientation()
|
||||
orientation.longitudinalSource = pitchIsLongitudinal > rollIsLongitudinal ? .pitch : .roll
|
||||
let longitudinal = orientation.longitudinalSource == .pitch ? nose.pitch : nose.roll
|
||||
let lateral = orientation.longitudinalSource == .pitch ? side.roll : side.pitch
|
||||
// Die zugeordnete Achse muss in ihrem Schritt auch deutlich gewandert
|
||||
// sein, sonst stünde das Vorzeichen auf wackligem Grund.
|
||||
guard abs(longitudinal) >= minimumTilt, abs(lateral) >= minimumTilt else {
|
||||
return .failure(.tooLittleMovement)
|
||||
}
|
||||
// Front nach unten heisst: das Heck steht höher, die Längsneigung des
|
||||
// Fahrzeugs ist also positiv.
|
||||
orientation.invertLongitudinal = longitudinal < 0
|
||||
// Linke Seite nach unten heisst: rechts steht höher, die Querneigung
|
||||
// ist positiv.
|
||||
orientation.invertLateral = lateral < 0
|
||||
|
||||
// Was nach dem Achsentausch noch übrig ist, ist die Verdrehung um die
|
||||
// Hochachse. Beim Kippen der Front nach unten dürfte sich nur die
|
||||
// Längsneigung ändern; wandert die Querneigung mit, sitzt der Sensor
|
||||
// schräg – und zwar um genau diesen Winkel.
|
||||
//
|
||||
// Gemessen wird an der Frontbewegung, nicht an der Seitenbewegung: Die
|
||||
// Front lässt sich am Fahrzeug genauer treffen, und beide Schritte
|
||||
// gemeinsam auszuwerten brächte hier nichts, weil eine Verdrehung auf
|
||||
// beide gleich wirkt.
|
||||
let corrected = orientation.apply(pitch: nose.pitch, roll: nose.roll)
|
||||
if let long = corrected.pitch, let lat = corrected.roll {
|
||||
let residual = atan2(lat, long) * 180 / .pi
|
||||
// Unter zwei Grad ist es Messrauschen. Zwei Kippbewegungen von Hand
|
||||
// sind nicht genauer, und eine erfundene Verdrehung wäre schlimmer
|
||||
// als keine.
|
||||
orientation.twist = abs(residual) >= minimumTwist ? residual : 0
|
||||
}
|
||||
return .success(orientation)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Was sich die Fahrzeugansichten auf iPhone und Uhr teilen.
|
||||
///
|
||||
/// Beide zeichnen dieselben Schablonen, nur in anderer Grösse – die Regeln,
|
||||
/// nach denen gekippt und eingefärbt wird, dürfen deshalb nicht zweimal
|
||||
/// dastehen und auseinanderlaufen.
|
||||
enum VehicleTilt {
|
||||
|
||||
/// Kleine Neigungen sind am Fahrzeug sonst kaum zu erkennen – zwei Grad
|
||||
/// wären ein knappes Grad Bildneigung. Die Überhöhung wird angeschrieben,
|
||||
/// damit niemand den Bildwinkel für den echten nimmt.
|
||||
static let exaggeration: Double = 3
|
||||
|
||||
/// Seitenansicht: Front links, Heck rechts.
|
||||
static let sideAspect = 925.0 / 600.0
|
||||
/// Heckansicht, quadratisch.
|
||||
static let rearAspect = 1.0
|
||||
|
||||
/// Grün heisst eben, orange bis zwei Grad, darüber rot.
|
||||
static func colour(for angle: Double?) -> Color {
|
||||
guard let angle else { return .secondary }
|
||||
if abs(angle) <= LevelState.levelTolerance { return .green }
|
||||
return abs(angle) <= 2 ? .orange : .red
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
/// Die Adressen, über die etwas in der Watch-App geöffnet wird.
|
||||
///
|
||||
/// Sie stehen hier, weil beide Seiten sie brauchen und sie sonst zweimal
|
||||
/// dastünden: Die Komplikation hängt sie an ihre Anzeige, die App wertet sie
|
||||
/// beim Öffnen aus. Ein Tippfehler auf einer Seite wäre stumm – der Tipp aufs
|
||||
/// Zifferblatt öffnete dann einfach die Geräteliste.
|
||||
enum WatchDeepLink {
|
||||
private static let scheme = "campermonitor"
|
||||
|
||||
/// Führt direkt auf die Nivellierung.
|
||||
static let level = URL(string: "\(scheme)://nivellierung")!
|
||||
|
||||
/// Ob diese Adresse zur Nivellierung führt.
|
||||
static func isLevel(_ url: URL) -> Bool {
|
||||
url.scheme == scheme && (url.host() == "nivellierung")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import Foundation
|
||||
|
||||
/// Was zwischen iPhone und Uhr hin- und hergeht.
|
||||
///
|
||||
/// Die Aufteilung folgt dem, was jedes Gerät hergibt:
|
||||
///
|
||||
/// * **Den Neigungsmesser funkt die Uhr selbst an** (`WatchLevelRadio`). Er
|
||||
/// bewirbt seinen Dienst, ist also ohne Einrichtung auffindbar, und er ist
|
||||
/// unverschlüsselt. Damit steht die Nivellierung am Handgelenk auch dann,
|
||||
/// wenn das iPhone in der Tasche schläft – und das ist der Fall, für den man
|
||||
/// die Uhr überhaupt anschaut.
|
||||
/// * **Alles andere kommt über das iPhone.** BMS und Kühlbox lassen nur eine
|
||||
/// Verbindung zu; eine mitlesende Uhr nähme dem iPhone die Verbindung weg,
|
||||
/// statt sie zu ergänzen. Und die Victron-Schlüssel liegen in der Keychain
|
||||
/// des iPhones – sie auf die Uhr zu kopieren hiesse, sie ein zweites Mal
|
||||
/// aufzubewahren, ohne dass der zweite Ort etwas brächte.
|
||||
///
|
||||
/// Übertragen werden deshalb die Messwerte der übrigen Geräte, dazu Fahrzeug
|
||||
/// und Einbaulage des Sensors: Beides braucht die Uhr, um aus den Rohwinkeln
|
||||
/// des Neigungsmessers dasselbe zu machen wie das iPhone.
|
||||
enum WatchLink {
|
||||
|
||||
/// Schlüssel im WatchConnectivity-Wörterbuch. Übertragen wird jeweils ein
|
||||
/// JSON-`Data`, nicht ein aufgedröseltes Wörterbuch: die Typen unten sind
|
||||
/// damit die einzige Stelle, an der das Format steht.
|
||||
static let payloadKey = "payload"
|
||||
static let commandKey = "command"
|
||||
|
||||
/// Wie lange ein „ich schaue gerade hin“ der Uhr gilt, ohne erneuert zu
|
||||
/// werden. Bricht die Verbindung ab oder wandert die App auf der Uhr in den
|
||||
/// Hintergrund, hört das iPhone von selbst wieder auf, im Sekundentakt zu
|
||||
/// senden.
|
||||
static let liveLease: TimeInterval = 12
|
||||
|
||||
/// Sendetakt, solange die Uhr hinschaut. Der Neigungsmesser wird zweimal je
|
||||
/// Sekunde abgefragt – schneller zu senden brächte nichts.
|
||||
static let liveInterval: TimeInterval = 0.5
|
||||
|
||||
/// Sendetakt sonst. Geht als Anwendungskontext raus, den watchOS auch dann
|
||||
/// noch zustellt, wenn die App auf der Uhr gerade nicht läuft.
|
||||
static let idleInterval: TimeInterval = 2
|
||||
}
|
||||
|
||||
/// Der gesamte Stand eines Fahrzeugs, so wie ihn die Uhr anzeigt.
|
||||
struct WatchPayload: Codable, Equatable, Sendable {
|
||||
/// Steigt, wenn sich das Format ändert. Eine Uhr mit älterer App bekommt
|
||||
/// sonst Werte, die sie falsch versteht – lieber sagt sie „App aktualisieren“.
|
||||
static let currentVersion = 1
|
||||
|
||||
var version = WatchPayload.currentVersion
|
||||
var generatedAt: Date
|
||||
/// Das am iPhone gewählte Fahrzeug.
|
||||
var profile: Profile
|
||||
/// Ob das iPhone gerade überhaupt funken kann.
|
||||
var isRadioReady: Bool
|
||||
/// Klartext dazu, falls nicht.
|
||||
var radioStatus: String
|
||||
var devices: [WatchDevice]
|
||||
|
||||
/// Nach der Rolle gesucht, nicht nach vorhandenen Werten: Ein
|
||||
/// Neigungsmesser, der gerade nicht antwortet, gehört trotzdem an seinen
|
||||
/// Platz – sonst rutschte er beim Verbindungsabriss in die Geräteliste.
|
||||
var levelDevice: WatchDevice? { devices.first { $0.role == .leveling } }
|
||||
// var fridgeDevice: WatchDevice? { devices.first { $0.role == .fridge } }
|
||||
|
||||
/// Ob sich inhaltlich nichts geändert hat. Der Zeitstempel zählt dabei
|
||||
/// nicht mit: sonst gälte jeder Datensatz als neu und die Uhr bekäme rund
|
||||
/// um die Uhr Funkverkehr, auch wenn das Fahrzeug still steht.
|
||||
func hasSameContent(as other: WatchPayload) -> Bool {
|
||||
var mine = self, theirs = other
|
||||
mine.generatedAt = .distantPast
|
||||
theirs.generatedAt = .distantPast
|
||||
return mine == theirs
|
||||
}
|
||||
|
||||
/// Wie alt der Stand ist. Auf der Uhr die wichtigste Angabe überhaupt:
|
||||
/// alles hier ist Weitergereichtes, und eine abgerissene Strecke zum
|
||||
/// iPhone sieht sonst aus wie ein stillstehendes Fahrzeug.
|
||||
func age(now: Date = Date()) -> TimeInterval { now.timeIntervalSince(generatedAt) }
|
||||
}
|
||||
|
||||
/// Ein Gerät mitsamt seinem letzten Messwertsatz.
|
||||
struct WatchDevice: Codable, Equatable, Identifiable, Sendable {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var role: DeviceRole
|
||||
var link: DeviceLinkState
|
||||
var snapshot: DeviceSnapshot?
|
||||
/// Nur beim Neigungsmesser belegt.
|
||||
var level: LevelState?
|
||||
/// Ebenfalls nur dort: wie der Sensor im Fahrzeug sitzt. Die Uhr funkt
|
||||
/// den Neigungsmesser selbst an und braucht die Zuordnung deshalb auch –
|
||||
/// eingestellt wird sie weiterhin nur am iPhone.
|
||||
var orientation: SensorOrientation?
|
||||
/// Nur bei der Kühlbox belegt.
|
||||
//var fridge: WatchFridge?
|
||||
|
||||
var isLive: Bool { link == .live && !(snapshot?.isStale ?? true) }
|
||||
}
|
||||
|
||||
/// Zustand und Grenzen der Kühlbox, so weit die Uhr sie braucht.
|
||||
///
|
||||
/// Eine eigene Struktur statt `AlpicoolState`: dort steckt der Rohdatensatz mit
|
||||
/// drin, den auf die Uhr zu schicken nichts brächte.
|
||||
/*struct WatchFridge: Codable, Equatable, Sendable {
|
||||
/// Ob die Box gerade wirklich verbunden ist.
|
||||
///
|
||||
/// Sie wird nur verbunden, während jemand ihre Ansicht offen hat – jede
|
||||
/// Verbindung meldet sich an ihrem Display an. Sonst stehen hier die
|
||||
/// zuletzt gestellten Werte, und die Messwerte fehlen.
|
||||
var isLive: Bool
|
||||
/// Wann der Stand zuletzt von der Box kam.
|
||||
var updated: Date
|
||||
var isPoweredOn: Bool
|
||||
var isEco: Bool
|
||||
var isLocked: Bool
|
||||
var isDualZone: Bool
|
||||
var unitSymbol: String
|
||||
var leftTarget: Int?
|
||||
var leftCurrent: Int?
|
||||
var rightTarget: Int?
|
||||
var rightCurrent: Int?
|
||||
var minTarget: Int
|
||||
var maxTarget: Int
|
||||
var batteryVolts: Double?
|
||||
|
||||
var targetRange: ClosedRange<Int> {
|
||||
minTarget < maxTarget ? minTarget...maxTarget : -30...20
|
||||
}
|
||||
}*/
|
||||
|
||||
/// Was die Uhr das iPhone tun lässt.
|
||||
///
|
||||
/// Gestellt wird nur, was auch am iPhone gestellt werden kann; die Uhr bekommt
|
||||
/// keine eigenen Fähigkeiten. Das Ergebnis liest sie wie jeder andere Wert aus
|
||||
/// dem nächsten Datensatz – bestätigt wird also das Gerät, nicht der Tastendruck.
|
||||
enum WatchCommand: Codable, Equatable, Sendable {
|
||||
/// Die Uhr schaut gerade hin und möchte den schnellen Takt.
|
||||
case hello(live: Bool)
|
||||
//case fridgePower(device: UUID, on: Bool)
|
||||
//case fridgeEco(device: UUID, eco: Bool)
|
||||
//case fridgeLock(device: UUID, locked: Bool)
|
||||
//case fridgeTarget(device: UUID, zone: WatchFridgeZone, value: Int)
|
||||
/// Die Kühlbox-Ansicht auf der Uhr wurde geöffnet oder geschlossen. Das
|
||||
/// iPhone verbindet die Box daraufhin – oder gibt sie wieder frei.
|
||||
//case fridgeSession(device: UUID, wanted: Bool)
|
||||
|
||||
/// Ob der Befehl den schnellen Sendetakt verlängert.
|
||||
var wantsLiveUpdates: Bool {
|
||||
if case .hello(let live) = self { return live }
|
||||
// Wer stellt, schaut auch hin.
|
||||
return true
|
||||
}
|
||||
}
|
||||
/*
|
||||
/// Spiegelt `AlpicoolState.Zone`, ohne das Protokoll auf die Uhr zu ziehen.
|
||||
enum WatchFridgeZone: String, Codable, Equatable, Sendable {
|
||||
case left, right
|
||||
}
|
||||
|
||||
// MARK: - Verpacken
|
||||
|
||||
extension WatchFridge {
|
||||
/// Aus dem zuletzt gestellten Stand, ohne Messwerte.
|
||||
init(_ settings: FridgeSettings) {
|
||||
self.init(isLive: false,
|
||||
updated: settings.updated,
|
||||
isPoweredOn: settings.isPoweredOn,
|
||||
isEco: settings.isEco,
|
||||
isLocked: settings.isLocked,
|
||||
isDualZone: settings.isDualZone,
|
||||
unitSymbol: settings.unitSymbol,
|
||||
leftTarget: settings.leftTarget,
|
||||
leftCurrent: nil,
|
||||
rightTarget: settings.rightTarget,
|
||||
rightCurrent: nil,
|
||||
minTarget: settings.usesFahrenheit ? -22 : -30,
|
||||
maxTarget: settings.usesFahrenheit ? 68 : 20,
|
||||
batteryVolts: nil)
|
||||
}
|
||||
}*/
|
||||
|
||||
extension WatchPayload {
|
||||
func encoded() throws -> Data { try WatchCoder.encoder.encode(self) }
|
||||
|
||||
/// Aus einem WatchConnectivity-Wörterbuch zurücklesen. Fremde oder zu neue
|
||||
/// Datensätze ergeben nil statt eines halb gefüllten Zustands.
|
||||
static func decode(from message: [String: Any]) -> WatchPayload? {
|
||||
guard let data = message[WatchLink.payloadKey] as? Data,
|
||||
let payload = try? WatchCoder.decoder.decode(WatchPayload.self, from: data),
|
||||
payload.version == WatchPayload.currentVersion else { return nil }
|
||||
return payload
|
||||
}
|
||||
|
||||
func message() throws -> [String: Any] { [WatchLink.payloadKey: try encoded()] }
|
||||
}
|
||||
|
||||
extension WatchCommand {
|
||||
func message() throws -> [String: Any] {
|
||||
[WatchLink.commandKey: try WatchCoder.encoder.encode(self)]
|
||||
}
|
||||
|
||||
static func decode(from message: [String: Any]) -> WatchCommand? {
|
||||
guard let data = message[WatchLink.commandKey] as? Data else { return nil }
|
||||
return try? WatchCoder.decoder.decode(WatchCommand.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Feste Einstellungen für beide Richtungen. Ohne das würde ein Datum auf der
|
||||
/// einen Seite anders geschrieben als auf der anderen gelesen.
|
||||
enum WatchCoder {
|
||||
static let encoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .millisecondsSince1970
|
||||
return encoder
|
||||
}()
|
||||
|
||||
static let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .millisecondsSince1970
|
||||
return decoder
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Die Keilhöhen als Draufsicht auf das Fahrzeug.
|
||||
///
|
||||
/// Vier Zahlen an vier Rädern, statt zweier Angaben „rechts" und „vorne", die
|
||||
/// sich nicht getrennt ausführen lassen. Oben ist vorne; links im Bild ist
|
||||
/// links am Fahrzeug, aus Sicht des Fahrers.
|
||||
///
|
||||
/// Das höchststehende Rad bekommt nichts – es bleibt liegen, die anderen
|
||||
/// werden auf seine Höhe gebracht.
|
||||
struct WheelLiftPlan: View {
|
||||
let lift: LevelingLift
|
||||
/// Für die Uhr: kleinere Schrift, engerer Aufbau.
|
||||
var isCompact = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: isCompact ? 2 : 6) {
|
||||
Text("vorne")
|
||||
.font(labelFont)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: isCompact ? 6 : 12) {
|
||||
VStack(spacing: isCompact ? 6 : 14) {
|
||||
badge(.frontLeft)
|
||||
badge(.rearLeft)
|
||||
}
|
||||
body_
|
||||
VStack(spacing: isCompact ? 6 : 14) {
|
||||
badge(.frontRight)
|
||||
badge(.rearRight)
|
||||
}
|
||||
}
|
||||
|
||||
Text("hinten")
|
||||
.font(labelFont)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(accessibilityText)
|
||||
}
|
||||
|
||||
/// Der Umriss dazwischen macht aus vier Zahlen ein Fahrzeug.
|
||||
private var body_: some View {
|
||||
RoundedRectangle(cornerRadius: isCompact ? 4 : 8)
|
||||
.strokeBorder(Color.secondary.opacity(0.4), lineWidth: 1)
|
||||
.frame(width: isCompact ? 26 : 48, height: isCompact ? 44 : 84)
|
||||
}
|
||||
|
||||
private func badge(_ wheel: LevelingLift.Wheel) -> some View {
|
||||
let value = lift.centimetres(wheel)
|
||||
let needed = value >= 1
|
||||
return Text(needed ? String(format: "%.0f", value) : "–")
|
||||
.font(valueFont)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(needed ? .primary : .secondary)
|
||||
.frame(width: isCompact ? 26 : 42, height: isCompact ? 20 : 30)
|
||||
.background(needed ? Color.orange.opacity(0.25) : Color.secondary.opacity(0.12),
|
||||
in: .rect(cornerRadius: isCompact ? 4 : 6))
|
||||
}
|
||||
|
||||
private var labelFont: Font { isCompact ? .system(size: 9) : .caption2 }
|
||||
private var valueFont: Font {
|
||||
isCompact ? .system(size: 12, weight: .semibold) : .callout.weight(.semibold)
|
||||
}
|
||||
|
||||
private var accessibilityText: String {
|
||||
let parts = LevelingLift.Wheel.allCases.compactMap { wheel -> String? in
|
||||
let value = lift.centimetres(wheel)
|
||||
guard value >= 1 else { return nil }
|
||||
return String(format: "%@ %.0f Zentimeter", wheel.text, value)
|
||||
}
|
||||
return parts.isEmpty ? "Keine Keile nötig" : "Unterlegen: " + parts.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user