diff --git a/CamperMonitor/Models/AlignmentAssistant.swift b/CamperMonitor/Models/AlignmentAssistant.swift new file mode 100644 index 0000000..5115980 --- /dev/null +++ b/CamperMonitor/Models/AlignmentAssistant.swift @@ -0,0 +1,183 @@ +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 ein Auffahrkeil sein muss, um eine Neigung auszugleichen. +/// +/// Rein geometrisch und damit exakt: Höhe = tan(Winkel) × Abstand der Achsen +/// beziehungsweise der Räder. +struct LevelingWedge: Equatable { + /// Wo der Keil hin muss. + let side: Side + /// Höhe in Metern. + let height: Double + /// Der zugrundeliegende Winkel in Grad. + let angle: Double + + enum Side: Equatable { + case front, rear, left, right + + var text: String { + switch self { + case .front: return "vorne" + case .rear: return "hinten" + case .left: return "links" + case .right: return "rechts" + } + } + } + + var heightInCentimetres: Double { height * 100 } + + /// Quer: die tieferliegende Seite muss angehoben werden. Positiver Roll + /// heisst, dass rechts höher steht – der Keil gehört also nach links. + static func across(roll: Double, trackWidth: Double) -> LevelingWedge? { + wedge(angle: roll, distance: trackWidth, + whenPositive: .left, whenNegative: .right) + } + + /// Längs: positiver Pitch heisst, dass das Heck höher steht – der Keil + /// gehört unter die Vorderräder. + static func along(pitch: Double, wheelbase: Double) -> LevelingWedge? { + wedge(angle: pitch, distance: wheelbase, + whenPositive: .front, whenNegative: .rear) + } + + private static func wedge(angle: Double, + distance: Double, + whenPositive: Side, + whenNegative: Side) -> LevelingWedge? { + guard distance > 0, abs(angle) > LevelState.levelTolerance else { return nil } + let height = tan(abs(angle) * .pi / 180) * distance + return LevelingWedge(side: angle > 0 ? whenPositive : whenNegative, + height: height, + angle: abs(angle)) + } +} diff --git a/CamperMonitor/Models/Profile.swift b/CamperMonitor/Models/Profile.swift index 7e2d65a..b7fd2ba 100644 --- a/CamperMonitor/Models/Profile.swift +++ b/CamperMonitor/Models/Profile.swift @@ -8,10 +8,31 @@ struct Profile: Identifiable, Codable, Hashable, Sendable { /// SF-Symbol für die Auswahl im Dashboard. var symbol: String - init(id: UUID = UUID(), name: String, symbol: String = "box.truck") { + /// 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 diff --git a/CamperMonitor/Store/DemoData.swift b/CamperMonitor/Store/DemoData.swift index caaedd3..c1160fd 100644 --- a/CamperMonitor/Store/DemoData.swift +++ b/CamperMonitor/Store/DemoData.swift @@ -20,7 +20,9 @@ enum DemoData { #endif } - static let mainProfile = Profile(id: Profile.defaultID, name: "Kastenwagen", symbol: "box.truck") + static let mainProfile = Profile(id: Profile.defaultID, name: "Kastenwagen", + symbol: "box.truck", + trackWidth: 1.85, wheelbase: 3.50) static let secondProfile = Profile( id: UUID(uuidString: "00000000-0000-0000-0000-0000000000C2")!, name: "Wohnwagen", symbol: "car.side") diff --git a/CamperMonitor/Views/AlignmentAssistantView.swift b/CamperMonitor/Views/AlignmentAssistantView.swift new file mode 100644 index 0000000..56ed9dc --- /dev/null +++ b/CamperMonitor/Views/AlignmentAssistantView.swift @@ -0,0 +1,195 @@ +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +/// Der Ausrichtungs-Assistent: begleitet das Rangieren und sagt, ob es besser +/// oder schlechter wird und wo es am besten stand. +struct AlignmentAssistantView: View { + let device: ConfiguredDevice + let profile: Profile? + + @Environment(BluetoothManager.self) private var bluetooth + @Environment(\.dismiss) private var dismiss + + @State private var assistant = AlignmentAssistant() + @State private var didAnnounceTarget = false + + private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 24) { + LevelBubble(pitch: state.pitch, roll: state.roll) + .frame(maxWidth: 320) + .padding(.top, 8) + + adviceBanner + + readings + + if let best = assistant.best, let gain = assistant.improvementAtBest, + let seconds = assistant.timeSinceBest { + bestPointCard(best: best, gain: gain, seconds: seconds) + } + + wedgeSection + + Button("Neu beginnen", systemImage: "arrow.counterclockwise") { + assistant.reset() + didAnnounceTarget = false + } + .buttonStyle(.bordered) + .padding(.bottom, 24) + } + .padding(.horizontal) + .frame(maxWidth: .infinity) + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("Ausrichtungs-Assistent") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Fertig") { dismiss() } + } + } + } + .onChange(of: state) { _, new in + guard let pitch = new.pitch, let roll = new.roll else { return } + assistant.add(pitch: pitch, roll: roll) + announceIfReached() + } + .onAppear { + // Den anliegenden Wert gleich übernehmen. Sonst stünde bis zur + // nächsten Messung "Warte auf Messwerte", obwohl längst welche da + // sind – die Ansicht wirkt dann tot. + if let pitch = state.pitch, let roll = state.roll { + assistant.add(pitch: pitch, roll: roll) + } + // Beim Rangieren schaut man immer wieder aufs Display; es darf + // dabei nicht dunkel werden. + #if canImport(UIKit) + UIApplication.shared.isIdleTimerDisabled = true + #endif + } + .onDisappear { + #if canImport(UIKit) + UIApplication.shared.isIdleTimerDisabled = false + #endif + } + } + + // MARK: - Bausteine + + private var adviceBanner: some View { + HStack(spacing: 12) { + Image(systemName: assistant.hasReachedTarget + ? "checkmark.circle.fill" : assistant.trend.symbol) + .font(.title) + .foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor) + Text(assistant.advice) + .font(.title3.weight(.medium)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding() + .background(assistant.hasReachedTarget ? Color.green.opacity(0.15) + : Color(.secondarySystemGroupedBackground), + in: .rect(cornerRadius: 16)) + } + + private var readings: some View { + HStack(spacing: 12) { + reading("Längs", state.pitch) + reading("Quer", state.roll) + reading("Gesamt", assistant.current?.deviation) + } + } + + private func reading(_ title: String, _ value: Double?) -> some View { + VStack(spacing: 4) { + Text(value.map { String(format: "%.1f°", $0) } ?? "–") + .font(.title2.weight(.semibold).monospacedDigit()) + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12)) + } + + private func bestPointCard(best: AlignmentAssistant.Sample, + gain: Double, + seconds: TimeInterval) -> some View { + VStack(alignment: .leading, spacing: 6) { + Label("Bester Punkt", systemImage: "flag.checkered") + .font(.headline) + Text(String(format: "Vor %.0f Sekunden stand das Fahrzeug %.1f° flacher (%.1f° statt %.1f°).", + seconds.rounded(), gain, best.deviation, + assistant.current?.deviation ?? 0)) + .font(.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16)) + } + + @ViewBuilder + private var wedgeSection: some View { + let across = profile?.trackWidth.flatMap { width in + state.roll.flatMap { LevelingWedge.across(roll: $0, trackWidth: width) } + } + let along = profile?.wheelbase.flatMap { base in + state.pitch.flatMap { LevelingWedge.along(pitch: $0, wheelbase: base) } + } + + VStack(alignment: .leading, spacing: 10) { + Label("Auffahrkeile", systemImage: "triangle.fill") + .font(.headline) + + if profile?.trackWidth == nil && profile?.wheelbase == nil { + Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt " + + "sich beim Fahrzeug hinterlegen.") + .font(.callout) + .foregroundStyle(.secondary) + } else if across == nil && along == nil { + Text("Keine Keile nötig.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach([across, along].compactMap { $0 }, id: \.side) { wedge in + HStack { + Text(wedge.side.text.capitalized) + Spacer() + Text(String(format: "%.0f cm", wedge.heightInCentimetres)) + .font(.title3.weight(.semibold).monospacedDigit()) + } + } + Text("Höhe unter die tieferstehende Seite, damit das Fahrzeug eben steht.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16)) + } + + /// Einmal spürbar melden, wenn die Waage erreicht ist – man schaut beim + /// Rangieren nicht dauernd aufs Display. + private func announceIfReached() { + guard assistant.hasReachedTarget else { + didAnnounceTarget = false + return + } + guard !didAnnounceTarget else { return } + didAnnounceTarget = true + #if canImport(UIKit) + UINotificationFeedbackGenerator().notificationOccurred(.success) + #endif + } +} + +extension LevelingWedge.Side: Hashable {} diff --git a/CamperMonitor/Views/LevelView.swift b/CamperMonitor/Views/LevelView.swift index 1fcc30b..45f7cae 100644 --- a/CamperMonitor/Views/LevelView.swift +++ b/CamperMonitor/Views/LevelView.swift @@ -106,7 +106,9 @@ struct LevelControls: View { let state: LevelState @Environment(BluetoothManager.self) private var bluetooth + @Environment(DeviceStore.self) private var store @State private var showResetConfirmation = false + @State private var showAssistant = false var body: some View { Section { @@ -131,6 +133,24 @@ struct LevelControls: View { .padding(.vertical, 8) } + Section { + Button { + showAssistant = true + } label: { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Ausrichtungs-Assistent") + Text("Begleitet das Rangieren") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "car.side.arrowtriangle.up.arrowtriangle.down") + } + } + .disabled(!state.hasReading) + } + Section { Button("Auf aktuelle Lage kalibrieren", systemImage: "scope") { bluetooth.calibrateLevel(for: device.id) @@ -157,6 +177,9 @@ struct LevelControls: View { + "Ob bereits kalibriert wurde, meldet dieses Gerät nicht zurück.") } } + .fullScreenCover(isPresented: $showAssistant) { + AlignmentAssistantView(device: device, profile: store.activeProfile) + } .confirmationDialog("Kalibrierung zurücksetzen?", isPresented: $showResetConfirmation, titleVisibility: .visible) { diff --git a/CamperMonitor/Views/ProfilesView.swift b/CamperMonitor/Views/ProfilesView.swift index 57e49a9..171b388 100644 --- a/CamperMonitor/Views/ProfilesView.swift +++ b/CamperMonitor/Views/ProfilesView.swift @@ -17,8 +17,9 @@ struct ProfilesView: View { row(for: profile) } } footer: { - Text("Jedes Fahrzeug hat seine eigenen Geräte. Die App liest immer " - + "nur die des gewählten Fahrzeugs aus.") + Text("Antippen wählt das Fahrzeug aus, das ⓘ öffnet Name, Symbol " + + "und Maße. Jedes Fahrzeug hat seine eigenen Geräte, und die " + + "App liest immer nur die des gewählten aus.") } Section { @@ -59,30 +60,42 @@ struct ProfilesView: View { } private func row(for profile: Profile) -> some View { - HStack { + HStack(spacing: 12) { Label { VStack(alignment: .leading, spacing: 2) { Text(profile.name) - Text(deviceSummary(for: profile)) + Text(summary(for: profile)) .font(.caption) .foregroundStyle(.secondary) } } icon: { Image(systemName: profile.symbol) } + // Nur dieser Bereich wählt aus; der Knopf rechts bleibt frei. + .contentShape(.rect) + .onTapGesture { + store.selectProfile(profile) + bluetooth.refreshConfiguration() + } - Spacer() + Spacer(minLength: 0) if profile.id == store.activeProfileID { Image(systemName: "checkmark") .foregroundStyle(.tint) .fontWeight(.semibold) } - } - .contentShape(.rect) - .onTapGesture { - store.selectProfile(profile) - bluetooth.refreshConfiguration() + + // Zum Bearbeiten reichte bisher nur das Wischen - das findet + // niemand, erst recht nicht für die Fahrzeugmaße. + Button { + editing = profile + } label: { + Image(systemName: "info.circle") + .font(.title3) + } + .buttonStyle(.borderless) + .accessibilityLabel("\(profile.name) bearbeiten") } .swipeActions(edge: .trailing) { // Das letzte Fahrzeug muss bleiben, sonst hätten Geräte keinen Ort. @@ -98,14 +111,34 @@ struct ProfilesView: View { } } - private func deviceSummary(for profile: Profile) -> String { + private func summary(for profile: Profile) -> String { let count = store.deviceCount(in: profile) - return count == 1 ? "1 Gerät" : "\(count) Geräte" + var parts = [count == 1 ? "1 Gerät" : "\(count) Geräte"] + if profile.trackWidth != nil || profile.wheelbase != nil { + parts.append("Maße hinterlegt") + } + return parts.joined(separator: " · ") } } -/// Name und Symbol eines Fahrzeugs ändern. +/// Name, Symbol und Maße eines Fahrzeugs ändern. private struct ProfileEditView: View { + + /// Nimmt Komma wie Punkt an – auf einer deutschen Tastatur liegt das Komma + /// näher. Unplausible Werte werden verworfen, damit die Keilhöhe nicht + /// stillschweigend Unsinn ergibt. + static func metres(from text: String) -> Double? { + let cleaned = text.replacingOccurrences(of: ",", with: ".") + .trimmingCharacters(in: .whitespaces) + guard let value = Double(cleaned), value > 0.5, value < 12 else { return nil } + return value + } + + static func text(from value: Double?) -> String { + guard let value else { return "" } + return String(format: "%.2f", value).replacingOccurrences(of: ".", with: ",") + } + let profile: Profile @Environment(DeviceStore.self) private var store @@ -113,6 +146,8 @@ private struct ProfileEditView: View { @State private var name = "" @State private var symbol = "box.truck" + @State private var trackWidth = "" + @State private var wheelbase = "" private let columns = [GridItem(.adaptive(minimum: 60), spacing: 12)] @@ -122,6 +157,31 @@ private struct ProfileEditView: View { Section("Name") { TextField("Name", text: $name) } + Section { + LabeledContent("Spurweite") { + HStack { + TextField("z. B. 2,00", text: $trackWidth) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + Text("m").foregroundStyle(.secondary) + } + } + LabeledContent("Radstand") { + HStack { + TextField("z. B. 3,50", text: $wheelbase) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + Text("m").foregroundStyle(.secondary) + } + } + } header: { + Text("Fahrzeugmaße") + } footer: { + Text("Nur für den Ausrichtungs-Assistenten: Aus Neigung und " + + "Abstand errechnet er, wie hoch der Auffahrkeil sein muss. " + + "Ohne Angabe entfällt diese Anzeige.") + } + Section("Symbol") { LazyVGrid(columns: columns, spacing: 12) { ForEach(Profile.symbols, id: \.self) { candidate in @@ -153,6 +213,8 @@ private struct ProfileEditView: View { var updated = profile updated.name = name.trimmingCharacters(in: .whitespaces) updated.symbol = symbol + updated.trackWidth = Self.metres(from: trackWidth) + updated.wheelbase = Self.metres(from: wheelbase) store.update(updated) dismiss() } @@ -162,6 +224,8 @@ private struct ProfileEditView: View { .onAppear { name = profile.name symbol = profile.symbol + trackWidth = Self.text(from: profile.trackWidth) + wheelbase = Self.text(from: profile.wheelbase) } } } diff --git a/README.md b/README.md index e3880a7..344e074 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,28 @@ Angabe, gilt sie als unbekannt und die App warnt nicht – eine Warnung, die sich nie abstellen lässt, wäre schlimmer als keine. Kalibrieren aus der App braucht Firmware 1.0.2. +### Ausrichtungs-Assistent + +Von der Nivellierungs-Ansicht aus erreichbar, gedacht fürs Rangieren. Er +verfolgt die Neigung über die Zeit und sagt, ob es gerade besser oder +schlechter wird – und wo es am besten stand: *„Vor 4 Sekunden stand das +Fahrzeug 0,8° flacher."* Steht der Camper in der Toleranz, meldet das Gerät +sich mit einer Vibration; das Display bleibt solange wach. + +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 und die Beschleunigungen im +Schritttempo ohnehin im Rauschen untergehen. Gebraucht wird das auch nicht: +Beim Einparken lautet die Frage nie „wo stehe ich", sondern „wird es besser". +Das steckt vollständig im zeitlichen Verlauf der Neigung – ohne jede Annahme +über das Gelände. + +Sind beim Fahrzeug **Spurweite und Radstand** hinterlegt, rechnet der Assistent +zusätzlich die nötige Höhe der Auffahrkeile aus. Das ist reine Geometrie und +damit exakt: 2,0° Querneigung bei 2,00 m Spurweite ergeben 7,0 cm unter die +tieferstehende Seite. Die Maße stehen im Fahrzeugprofil, erreichbar über das +ⓘ in der Fahrzeugliste. + ## Mehrere Fahrzeuge Oben links im Dashboard sitzt der Fahrzeugwechsel. Jedes Profil hat seinen @@ -184,7 +206,8 @@ firmware/vanalign/ Firmware des Neigungsmessers (ESPHome) CamperMonitor/ ├── Models/ -│ ├── Profile.swift Fahrzeug +│ ├── Profile.swift Fahrzeug samt Maßen +│ ├── AlignmentAssistant.swift Verlauf, Tendenz und Keilberechnung │ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart │ ├── DeviceSnapshot.swift Messwerte in Anzeigeform │ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes @@ -211,6 +234,7 @@ CamperMonitor/ ├── ProfilesView.swift Fahrzeuge anlegen und verwalten ├── FridgeControls.swift Bedienelemente der Kühlbox ├── LevelView.swift Libelle und Kalibrierung + ├── AlignmentAssistantView.swift Ausrichtungs-Assistent fürs Rangieren └── AddDeviceView.swift Scannen und Einrichten ``` diff --git a/Tests/main.swift b/Tests/main.swift index 575a7ec..2ec4703 100644 --- a/Tests/main.swift +++ b/Tests/main.swift @@ -672,5 +672,74 @@ checkEqual("belegt fehlende Kalibrierung wird gemeldet", uncalibrated.snapshot(deviceID: UUID(), rssi: nil).offReasons.first, "Nicht kalibriert") +// MARK: 11 – Ausrichtungs-Assistent +print("\nAusrichtungs-Assistent") + +let start = Date(timeIntervalSince1970: 1_700_000_000) +var assistant = AlignmentAssistant() +checkEqual("ohne Messwerte kein Rat", assistant.trend, .unknown) + +// Das Fahrzeug wird langsam flacher. +for (index, deviation) in [3.0, 2.6, 2.2, 1.8, 1.4, 1.0].enumerated() { + assistant.add(pitch: deviation, roll: 0, at: start.addingTimeInterval(Double(index) * 0.5)) +} +checkEqual("fallende Abweichung wird als Verbesserung erkannt", assistant.trend, .improving) +checkEqual("kein Rückwärtshinweis, solange es besser wird", + assistant.improvementAtBest == nil, true) +checkEqual("Rat beim Verbessern", assistant.advice, "Wird besser – weiter so") + +// Jetzt am flachsten Punkt vorbei. +for (index, deviation) in [1.4, 1.9, 2.5].enumerated() { + assistant.add(pitch: deviation, roll: 0, at: start.addingTimeInterval(3.0 + Double(index) * 0.5)) +} +checkEqual("steigende Abweichung wird als Verschlechterung erkannt", assistant.trend, .worsening) +checkEqual("bester Punkt wird gefunden", assistant.best?.deviation.rounded(), 1) +check("Rückwärtshinweis erscheint", assistant.improvementAtBest != nil) +checkEqual("Rat verweist auf den besseren Punkt", + assistant.advice.hasPrefix("Vor ") && assistant.advice.hasSuffix("zurück"), true) +checkEqual("Zeit seit dem besten Punkt", + assistant.timeSinceBest.map { ($0 * 10).rounded() / 10 }, 1.5) + +// Erreicht das Fahrzeug die Waage, zählt nur noch das. +assistant.add(pitch: 0.2, roll: 0.1, at: start.addingTimeInterval(5)) +checkEqual("Ziel erreicht", assistant.hasReachedTarget, true) +checkEqual("Rat beim Ziel", assistant.advice, "Steht eben – anhalten") + +// Gesamtabweichung über beide Achsen. +var both = AlignmentAssistant() +both.add(pitch: 3, roll: 4, at: start) +checkEqual("Gesamtabweichung ist der Betrag beider Achsen", + both.current?.deviation.rounded(), 5) + +// Der Speicher endet nach der festgelegten Zeit. +var ageing = AlignmentAssistant() +ageing.add(pitch: 5, roll: 0, at: start) +ageing.add(pitch: 1, roll: 0, at: start.addingTimeInterval(AlignmentAssistant.memory + 10)) +checkEqual("alte Messwerte fallen heraus", ageing.samples.count, 1) + +// MARK: Auffahrkeile +print("\nAuffahrkeile") + +// tan(2°) x 2,00 m = 6,99 cm +let acrossWedge = LevelingWedge.across(roll: 2.0, trackWidth: 2.0) +checkEqual("Keilhöhe quer", + acrossWedge.map { ($0.heightInCentimetres * 10).rounded() / 10 }, 7.0) +checkEqual("rechts höher heisst Keil nach links", acrossWedge?.side, .left) +checkEqual("links höher heisst Keil nach rechts", + LevelingWedge.across(roll: -2.0, trackWidth: 2.0)?.side, .right) + +// tan(1,5°) x 3,50 m = 9,16 cm +let alongWedge = LevelingWedge.along(pitch: 1.5, wheelbase: 3.5) +checkEqual("Keilhöhe längs", + alongWedge.map { ($0.heightInCentimetres * 10).rounded() / 10 }, 9.2) +checkEqual("Heck höher heisst Keil nach vorne", alongWedge?.side, .front) +checkEqual("Front höher heisst Keil nach hinten", + LevelingWedge.along(pitch: -1.5, wheelbase: 3.5)?.side, .rear) + +checkEqual("innerhalb der Toleranz kein Keil", + LevelingWedge.across(roll: 0.3, trackWidth: 2.0) == nil, true) +checkEqual("ohne Maß kein Keil", + LevelingWedge.across(roll: 3.0, trackWidth: 0) == nil, true) + print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.") exit(failures == 0 ? 0 : 1) diff --git a/run-tests.sh b/run-tests.sh index 420bbb6..9b7cf54 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -17,6 +17,7 @@ swiftc -O -o "$OUT/tests" \ CamperMonitor/Models/DeviceSnapshot.swift \ CamperMonitor/Models/ConfiguredDevice.swift \ CamperMonitor/Models/Profile.swift \ + CamperMonitor/Models/AlignmentAssistant.swift \ CamperMonitor/Models/VictronCodes.swift \ CamperMonitor/Store/KeychainStore.swift \ Tests/main.swift