Ausrichtungs-Assistent fürs Rangieren

Begleitet das Einparken: verfolgt die Neigung über die Zeit, meldet ob es
besser oder schlechter wird, und erinnert an den flachsten Punkt - "vor 4
Sekunden stand das Fahrzeug 0,8 Grad flacher". Bei Erreichen der Toleranz
gibt es eine Vibration, das Display bleibt währenddessen wach.

Bewusst ohne Positionsbestimmung. Aus einem MEMS-Sensor lässt sich keine
brauchbare Strecke ableiten: der Fehler wächst beim zweifachen Integrieren
quadratisch mit der Zeit, und im Schritttempo gehen die tatsächlichen
Beschleunigungen im Rauschen unter. Gebraucht wird sie 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. Die vorhandene Verlaufsaufzeichnung war mit
einem Punkt alle fünf Sekunden zu grob; der Assistent führt einen eigenen
Ringpuffer, der nur läuft solange die Ansicht offen ist.

Sind Spurweite und Radstand hinterlegt, kommt die nötige Höhe der
Auffahrkeile dazu. Das ist reine Geometrie und damit exakt. Die Masse
stehen im Fahrzeugprofil.

Dabei fiel auf, dass sich vorhandene Fahrzeuge praktisch nicht bearbeiten
liessen: das ging nur über eine Wischgeste, die niemand findet - erst
recht nicht, wenn dort jetzt die Masse einzutragen sind. Die Zeile hat nun
einen sichtbaren Knopf dafür, wie bei den WLAN-Einstellungen: antippen
wählt aus, das Zeichen daneben öffnet die Bearbeitung.

Ausserdem übernimmt der Assistent den anliegenden Messwert beim Öffnen.
Sonst stand dort bis zur nächsten Messung "Warte auf Messwerte", obwohl
längst welche vorlagen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
BiasF
2026-08-30 15:52:57 +02:00
co-authored by Claude Opus 5
parent 826a201a36
commit 767fe4e798
9 changed files with 598 additions and 16 deletions
@@ -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))
}
}
+22 -1
View File
@@ -8,10 +8,31 @@ struct Profile: Identifiable, Codable, Hashable, Sendable {
/// SF-Symbol für die Auswahl im Dashboard. /// SF-Symbol für die Auswahl im Dashboard.
var symbol: String 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.id = id
self.name = name self.name = name
self.symbol = symbol 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 /// Profil, dem Geräte aus der Zeit vor der Profilverwaltung zugeordnet
+3 -1
View File
@@ -20,7 +20,9 @@ enum DemoData {
#endif #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( static let secondProfile = Profile(
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000C2")!, id: UUID(uuidString: "00000000-0000-0000-0000-0000000000C2")!,
name: "Wohnwagen", symbol: "car.side") name: "Wohnwagen", symbol: "car.side")
@@ -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 {}
+23
View File
@@ -106,7 +106,9 @@ struct LevelControls: View {
let state: LevelState let state: LevelState
@Environment(BluetoothManager.self) private var bluetooth @Environment(BluetoothManager.self) private var bluetooth
@Environment(DeviceStore.self) private var store
@State private var showResetConfirmation = false @State private var showResetConfirmation = false
@State private var showAssistant = false
var body: some View { var body: some View {
Section { Section {
@@ -131,6 +133,24 @@ struct LevelControls: View {
.padding(.vertical, 8) .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 { Section {
Button("Auf aktuelle Lage kalibrieren", systemImage: "scope") { Button("Auf aktuelle Lage kalibrieren", systemImage: "scope") {
bluetooth.calibrateLevel(for: device.id) bluetooth.calibrateLevel(for: device.id)
@@ -157,6 +177,9 @@ struct LevelControls: View {
+ "Ob bereits kalibriert wurde, meldet dieses Gerät nicht zurück.") + "Ob bereits kalibriert wurde, meldet dieses Gerät nicht zurück.")
} }
} }
.fullScreenCover(isPresented: $showAssistant) {
AlignmentAssistantView(device: device, profile: store.activeProfile)
}
.confirmationDialog("Kalibrierung zurücksetzen?", .confirmationDialog("Kalibrierung zurücksetzen?",
isPresented: $showResetConfirmation, isPresented: $showResetConfirmation,
titleVisibility: .visible) { titleVisibility: .visible) {
+76 -12
View File
@@ -17,8 +17,9 @@ struct ProfilesView: View {
row(for: profile) row(for: profile)
} }
} footer: { } footer: {
Text("Jedes Fahrzeug hat seine eigenen Geräte. Die App liest immer " Text("Antippen wählt das Fahrzeug aus, das ⓘ öffnet Name, Symbol "
+ "nur die des gewählten Fahrzeugs aus.") + "und Maße. Jedes Fahrzeug hat seine eigenen Geräte, und die "
+ "App liest immer nur die des gewählten aus.")
} }
Section { Section {
@@ -59,30 +60,42 @@ struct ProfilesView: View {
} }
private func row(for profile: Profile) -> some View { private func row(for profile: Profile) -> some View {
HStack { HStack(spacing: 12) {
Label { Label {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(profile.name) Text(profile.name)
Text(deviceSummary(for: profile)) Text(summary(for: profile))
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} icon: { } icon: {
Image(systemName: profile.symbol) 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 { if profile.id == store.activeProfileID {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.foregroundStyle(.tint) .foregroundStyle(.tint)
.fontWeight(.semibold) .fontWeight(.semibold)
} }
// 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)
} }
.contentShape(.rect) .buttonStyle(.borderless)
.onTapGesture { .accessibilityLabel("\(profile.name) bearbeiten")
store.selectProfile(profile)
bluetooth.refreshConfiguration()
} }
.swipeActions(edge: .trailing) { .swipeActions(edge: .trailing) {
// Das letzte Fahrzeug muss bleiben, sonst hätten Geräte keinen Ort. // 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) 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 { 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 let profile: Profile
@Environment(DeviceStore.self) private var store @Environment(DeviceStore.self) private var store
@@ -113,6 +146,8 @@ private struct ProfileEditView: View {
@State private var name = "" @State private var name = ""
@State private var symbol = "box.truck" @State private var symbol = "box.truck"
@State private var trackWidth = ""
@State private var wheelbase = ""
private let columns = [GridItem(.adaptive(minimum: 60), spacing: 12)] private let columns = [GridItem(.adaptive(minimum: 60), spacing: 12)]
@@ -122,6 +157,31 @@ private struct ProfileEditView: View {
Section("Name") { Section("Name") {
TextField("Name", text: $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") { Section("Symbol") {
LazyVGrid(columns: columns, spacing: 12) { LazyVGrid(columns: columns, spacing: 12) {
ForEach(Profile.symbols, id: \.self) { candidate in ForEach(Profile.symbols, id: \.self) { candidate in
@@ -153,6 +213,8 @@ private struct ProfileEditView: View {
var updated = profile var updated = profile
updated.name = name.trimmingCharacters(in: .whitespaces) updated.name = name.trimmingCharacters(in: .whitespaces)
updated.symbol = symbol updated.symbol = symbol
updated.trackWidth = Self.metres(from: trackWidth)
updated.wheelbase = Self.metres(from: wheelbase)
store.update(updated) store.update(updated)
dismiss() dismiss()
} }
@@ -162,6 +224,8 @@ private struct ProfileEditView: View {
.onAppear { .onAppear {
name = profile.name name = profile.name
symbol = profile.symbol symbol = profile.symbol
trackWidth = Self.text(from: profile.trackWidth)
wheelbase = Self.text(from: profile.wheelbase)
} }
} }
} }
+25 -1
View File
@@ -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 sich nie abstellen lässt, wäre schlimmer als keine. Kalibrieren aus der App
braucht Firmware 1.0.2. 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 ## Mehrere Fahrzeuge
Oben links im Dashboard sitzt der Fahrzeugwechsel. Jedes Profil hat seinen Oben links im Dashboard sitzt der Fahrzeugwechsel. Jedes Profil hat seinen
@@ -184,7 +206,8 @@ firmware/vanalign/ Firmware des Neigungsmessers (ESPHome)
CamperMonitor/ CamperMonitor/
├── Models/ ├── Models/
│ ├── Profile.swift Fahrzeug │ ├── Profile.swift Fahrzeug samt Maßen
│ ├── AlignmentAssistant.swift Verlauf, Tendenz und Keilberechnung
│ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart │ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart
│ ├── DeviceSnapshot.swift Messwerte in Anzeigeform │ ├── DeviceSnapshot.swift Messwerte in Anzeigeform
│ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes │ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes
@@ -211,6 +234,7 @@ CamperMonitor/
├── ProfilesView.swift Fahrzeuge anlegen und verwalten ├── ProfilesView.swift Fahrzeuge anlegen und verwalten
├── FridgeControls.swift Bedienelemente der Kühlbox ├── FridgeControls.swift Bedienelemente der Kühlbox
├── LevelView.swift Libelle und Kalibrierung ├── LevelView.swift Libelle und Kalibrierung
├── AlignmentAssistantView.swift Ausrichtungs-Assistent fürs Rangieren
└── AddDeviceView.swift Scannen und Einrichten └── AddDeviceView.swift Scannen und Einrichten
``` ```
+69
View File
@@ -672,5 +672,74 @@ checkEqual("belegt fehlende Kalibrierung wird gemeldet",
uncalibrated.snapshot(deviceID: UUID(), rssi: nil).offReasons.first, uncalibrated.snapshot(deviceID: UUID(), rssi: nil).offReasons.first,
"Nicht kalibriert") "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.") print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
exit(failures == 0 ? 0 : 1) exit(failures == 0 ? 0 : 1)
+1
View File
@@ -17,6 +17,7 @@ swiftc -O -o "$OUT/tests" \
CamperMonitor/Models/DeviceSnapshot.swift \ CamperMonitor/Models/DeviceSnapshot.swift \
CamperMonitor/Models/ConfiguredDevice.swift \ CamperMonitor/Models/ConfiguredDevice.swift \
CamperMonitor/Models/Profile.swift \ CamperMonitor/Models/Profile.swift \
CamperMonitor/Models/AlignmentAssistant.swift \
CamperMonitor/Models/VictronCodes.swift \ CamperMonitor/Models/VictronCodes.swift \
CamperMonitor/Store/KeychainStore.swift \ CamperMonitor/Store/KeychainStore.swift \
Tests/main.swift Tests/main.swift