forked from fritob/Camper-Monitor
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:
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user