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
BIN
View File
Binary file not shown.
+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)
}
}
+213
View File
@@ -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]!)
}
}
+139
View File
@@ -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
}
}
+97
View File
@@ -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
}
}
}
+80
View File
@@ -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
}
}
+53
View File
@@ -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",
]
}
+203
View File
@@ -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)
}
}
+26
View File
@@ -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
}
}
+19
View File
@@ -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")
}
}
+223
View File
@@ -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
}()
}
+74
View File
@@ -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: ", ")
}
}