Neigungsmesser VanAlign einbinden
Das ESPHome-Projekt VanAlign Pro misst über einen MPU6050 die Längs- und Querneigung des Fahrzeugs und stellt sie über Bluetooth bereit. Damit lässt sich der Camper beim Parken ausrichten. Der Neigungsmesser bewirbt seinen Dienst, wird beim Einrichten also sicher erkannt und die Geräteart vorbelegt. Anders als bei den übrigen verbundenen Geräten gibt es hier kein Rahmenprotokoll: jede Messgrösse liegt in einer eigenen Charakteristik als Float. LevelSession ist deshalb eine eigene, deutlich einfachere Sitzungsart neben BMSSession - ohne Protokollerkennung und ohne Kandidatensuche. Bevorzugt werden die Werte abonniert; bietet das Gerät das nicht an, wird im Takt abgefragt. Damit läuft die App auch mit der alten Firmware 1.0, die nur Lesen kennt. Die Detailansicht zeigt eine Libelle: die Blase wandert dorthin, wo das Fahrzeug höher steht. Grün bis 0,5 Grad, orange bis zwei, darüber rot - die Akzentfarbe der App ist selbst grün, "schief" hätte sonst genauso ausgesehen wie "eben". Die Ringe sind ein echter Massstab (Toleranz und zwei Grad), sonst sagt die Blasenlage nichts über die verbleibende Abweichung. Dazu die Ansage in Worten, welche Seite höher steht. Die Winkel werden fest little-endian gelesen. Die Web-Oberfläche des Ursprungsprojekts probiert zusätzlich die umgekehrte Reihenfolge, falls die erste unplausibel wirkt. Das ist nicht nur unnötig, sondern schädlich: ein vertauschter Float von 4,25 Grad liest sich als etwa 0,0 und wirkt damit völlig plausibel - der Fehler bliebe unbemerkt. Ein Test hält das fest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -193,6 +193,8 @@ private struct ConfigureDeviceView: View {
|
||||
case .batteryMonitor: role = .batteryMonitor
|
||||
default: role = .solarCharger
|
||||
}
|
||||
} else if discovery.isLevelSensor {
|
||||
role = .leveling
|
||||
} else if let name = discovery.name?.lowercased(),
|
||||
["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) {
|
||||
role = .fridge
|
||||
@@ -202,7 +204,9 @@ private struct ConfigureDeviceView: View {
|
||||
// Funknamen wie "WTaEaAA25342229" taugen nicht als Anzeigename. Die
|
||||
// Art des Geräts ist der bessere Vorschlag; der Funkname steht
|
||||
// ohnehin darüber unter "Gefunden als".
|
||||
if let advertised = discovery.name, advertised.count <= 20,
|
||||
if discovery.isLevelSensor {
|
||||
name = "Nivellierung"
|
||||
} else if let advertised = discovery.name, advertised.count <= 20,
|
||||
advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil {
|
||||
name = advertised
|
||||
} else {
|
||||
|
||||
@@ -30,6 +30,11 @@ struct DeviceDetailView: View {
|
||||
FridgeControls(device: currentDevice, state: fridge)
|
||||
}
|
||||
|
||||
if currentDevice.role == .leveling {
|
||||
LevelControls(device: currentDevice,
|
||||
state: bluetooth.levelStates[device.id] ?? LevelState())
|
||||
}
|
||||
|
||||
if let snapshot, !snapshot.metrics.isEmpty {
|
||||
Section("Messwerte") {
|
||||
ForEach(snapshot.metrics) { metric in
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Grafische Libelle: eine Blase, die zeigt, wohin das Fahrzeug hängt.
|
||||
///
|
||||
/// Die Blase wandert dorthin, wo das Fahrzeug **höher** steht – so, wie sich
|
||||
/// eine echte Wasserwaage verhält. Wer sie mittig haben will, muss also die
|
||||
/// Gegenseite anheben.
|
||||
struct LevelBubble: View {
|
||||
let pitch: Double?
|
||||
let roll: Double?
|
||||
/// Bis zu welcher Neigung die Anzeige ausschlägt.
|
||||
var range: Double = 6
|
||||
|
||||
private var isLevel: Bool {
|
||||
guard let pitch, let roll else { return false }
|
||||
return abs(pitch) <= LevelState.levelTolerance
|
||||
&& abs(roll) <= LevelState.levelTolerance
|
||||
}
|
||||
|
||||
/// Grün nur, wenn es wirklich eben ist. Die Akzentfarbe der App ist selbst
|
||||
/// grün – damit sähe "schief" genauso aus wie "eben".
|
||||
private var bubbleColor: Color {
|
||||
guard let deviation = maxDeviation else { return .secondary }
|
||||
if deviation <= LevelState.levelTolerance { return .green }
|
||||
if deviation <= 2 { return .orange }
|
||||
return .red
|
||||
}
|
||||
|
||||
private var maxDeviation: Double? {
|
||||
switch (pitch, roll) {
|
||||
case let (p?, r?): return max(abs(p), abs(r))
|
||||
case let (p?, nil): return abs(p)
|
||||
case let (nil, r?): return abs(r)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let side = min(geometry.size.width, geometry.size.height)
|
||||
let radius = side / 2
|
||||
let bubble = side * 0.16
|
||||
// Die Blase darf den Rand nicht verlassen, auch bei starker Neigung.
|
||||
let travel = radius - bubble / 2 - 4
|
||||
|
||||
let offsetX = clamped(roll) / range * travel
|
||||
let offsetY = clamped(pitch) / range * travel
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(.secondarySystemFill))
|
||||
Circle()
|
||||
.strokeBorder(Color.secondary.opacity(0.35), lineWidth: 1)
|
||||
|
||||
// Ringe als echter Maßstab: der innere markiert die Toleranz,
|
||||
// der mittlere zwei Grad. Ohne Maßstab sagt die Blasenlage
|
||||
// nichts darüber, wie weit es noch ist.
|
||||
let toleranceRadius = max(LevelState.levelTolerance / range * travel, bubble * 0.6)
|
||||
Circle()
|
||||
.strokeBorder(isLevel ? Color.green : Color.secondary.opacity(0.5),
|
||||
lineWidth: isLevel ? 2 : 1)
|
||||
.frame(width: toleranceRadius * 2, height: toleranceRadius * 2)
|
||||
Circle()
|
||||
.strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1)
|
||||
.frame(width: 2.0 / range * travel * 2, height: 2.0 / range * travel * 2)
|
||||
|
||||
Path { path in
|
||||
path.move(to: CGPoint(x: radius - travel, y: radius))
|
||||
path.addLine(to: CGPoint(x: radius + travel, y: radius))
|
||||
path.move(to: CGPoint(x: radius, y: radius - travel))
|
||||
path.addLine(to: CGPoint(x: radius, y: radius + travel))
|
||||
}
|
||||
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)
|
||||
|
||||
Circle()
|
||||
.fill(bubbleColor)
|
||||
.frame(width: bubble, height: bubble)
|
||||
// Positiver Pitch heisst: das Heck steht höher, die Blase
|
||||
// wandert also nach oben – in der Ansicht nach hinten.
|
||||
.offset(x: offsetX, y: -offsetY)
|
||||
.animation(.spring(duration: 0.35), value: offsetX)
|
||||
.animation(.spring(duration: 0.35), value: offsetY)
|
||||
.opacity(pitch == nil && roll == nil ? 0.25 : 1)
|
||||
}
|
||||
.frame(width: side, height: side)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.accessibilityLabel(accessibilityText)
|
||||
}
|
||||
|
||||
private func clamped(_ value: Double?) -> Double {
|
||||
guard let value else { return 0 }
|
||||
return min(max(value, -range), range)
|
||||
}
|
||||
|
||||
private var accessibilityText: String {
|
||||
guard let pitch, let roll else { return "Keine Messwerte" }
|
||||
return String(format: "Längsneigung %.1f Grad, Querneigung %.1f Grad", pitch, roll)
|
||||
}
|
||||
}
|
||||
|
||||
/// Anzeige und Kalibrierung des Neigungsmessers.
|
||||
struct LevelControls: View {
|
||||
let device: ConfiguredDevice
|
||||
let state: LevelState
|
||||
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@State private var showResetConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
VStack(spacing: 16) {
|
||||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||||
.frame(maxHeight: 220)
|
||||
|
||||
if let instruction = state.instruction {
|
||||
Label(instruction,
|
||||
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
||||
.font(.headline)
|
||||
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
HStack(spacing: 24) {
|
||||
reading("Längs", state.pitch)
|
||||
reading("Quer", state.roll)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Auf aktuelle Lage kalibrieren", systemImage: "scope") {
|
||||
bluetooth.calibrateLevel(for: device.id)
|
||||
}
|
||||
if state.isCalibrated {
|
||||
Button("Kalibrierung zurücksetzen", systemImage: "arrow.uturn.backward", role: .destructive) {
|
||||
showResetConfirmation = true
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Kalibrierung")
|
||||
} footer: {
|
||||
if state.isCalibrated, let pitchOffset = state.pitchOffset,
|
||||
let rollOffset = state.rollOffset {
|
||||
Text(String(format: "Nullpunkt liegt bei %.1f° längs und %.1f° quer. "
|
||||
+ "Zum Kalibrieren das Fahrzeug eben stellen und dann tippen.",
|
||||
pitchOffset, rollOffset))
|
||||
} else {
|
||||
Text("Noch nicht kalibriert – die Anzeige zeigt die Lage des Sensors, "
|
||||
+ "nicht die des Fahrzeugs. Fahrzeug eben stellen und dann kalibrieren.")
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Kalibrierung zurücksetzen?",
|
||||
isPresented: $showResetConfirmation,
|
||||
titleVisibility: .visible) {
|
||||
Button("Zurücksetzen", role: .destructive) {
|
||||
bluetooth.resetLevelCalibration(for: device.id)
|
||||
}
|
||||
} message: {
|
||||
Text("Die Anzeige zeigt danach wieder die Lage des Sensors.")
|
||||
}
|
||||
}
|
||||
|
||||
private func reading(_ title: String, _ value: Double?) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(value.map { String(format: "%.1f°", $0) } ?? "–")
|
||||
.font(.title2.weight(.semibold).monospacedDigit())
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user