Zwei Fehler aus dem letzten Stand, beide von mir eingebaut. Die Kühlbox liess sich nicht mehr bedienen. Die Wartesperre gegen den Verbindungssturm hat nicht unterschieden, warum eine Verbindung endete: ein Abbruch einer Verbindung, die stand, wurde genauso lange gesperrt wie ein Fehlschlag. Im Fahrzeug ist so ein Abbruch der Normalfall, und die Box war danach bis zu einer Minute nicht erreichbar. Ein Tippen ging in dieser Zeit lautlos verloren, weil es ohne Sitzung ins Leere fiel. Jetzt zählt die Dauer: was mindestens drei Sekunden stand, gilt als zustandegekommen und wird nach zwei Sekunden wieder aufgenommen. Nur was sofort wieder abbricht, zählt als Fehlschlag und wird gestaffelt gesperrt - gegen das Wechselspiel, wenn eine andere App das Gerät belegt. Dazu gehen Stellbefehle nicht mehr verloren. Fehlt die Verbindung, wird der Befehl aufgehoben, die Sperre für dieses Gerät aufgehoben und sofort ein Versuch angestossen; sobald der Kanal steht, geht er raus. Ein Befehl, der älter als eine halbe Minute ist, verfällt - ein verspätetes Ausschalten würde nur überraschen. Die Ansicht sagt es, statt den Schalter stumm zurückspringen zu lassen. Zweitens der Assistent für die Einbaulage. Er hat die zweite Bewegung von der gekippten Lage aus gemessen statt von der Ruhelage. Damit steckte in jeder zweiten Messung das Zurückkippen aus der ersten, und es schlugen immer beide Achsen aus. Die Ruhelage ist jetzt der Bezug für beide Bewegungen, und ein eigener Schritt dazwischen nimmt sie neu auf. Auch die Auswertung war zu streng: sie hat jede Bewegung für sich beurteilt und abgelehnt, sobald die andere Achse mitlief. Von Hand gekippt läuft sie aber immer mit. Beurteilt werden jetzt beide Bewegungen gemeinsam - es gewinnt die Zuordnung, die zusammen am meisten erklärt. Zweimal um dieselbe Achse gekippt wird weiterhin erkannt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
202 lines
7.6 KiB
Swift
202 lines
7.6 KiB
Swift
import SwiftUI
|
||
|
||
/// Führt durch die Einrichtung der Einbaulage des Neigungsmessers.
|
||
///
|
||
/// Der Sensor kann quer, gedreht oder kopfüber sitzen. Statt die Lage aus einer
|
||
/// Liste raten zu lassen, wird sie gemessen: zweimal kippen, einmal um jede
|
||
/// Achse, und aus der Reaktion ergibt sich die Zuordnung.
|
||
struct SensorSetupView: View {
|
||
let device: ConfiguredDevice
|
||
|
||
@Environment(DeviceStore.self) private var store
|
||
@Environment(BluetoothManager.self) private var bluetooth
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
private enum Step {
|
||
case intro
|
||
case tiltNose
|
||
case settle
|
||
case tiltSide
|
||
case done(SensorOrientation)
|
||
case failed(OrientationDetection.Failure)
|
||
}
|
||
|
||
@State private var step: Step = .intro
|
||
/// Ruhelage, auf die beide Kippbewegungen bezogen werden.
|
||
@State private var reference = OrientationDetection.Reading(pitch: 0, roll: 0)
|
||
@State private var noseChange: OrientationDetection.Reading?
|
||
|
||
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
|
||
|
||
private var live: OrientationDetection.Reading? {
|
||
guard let pitch = state.rawPitch, let roll = state.rawRoll else { return nil }
|
||
return OrientationDetection.Reading(pitch: pitch, roll: roll)
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
ScrollView {
|
||
VStack(spacing: 24) {
|
||
content
|
||
}
|
||
.padding()
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.background(Color(.systemGroupedBackground))
|
||
.navigationTitle("Einbaulage")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button("Abbrechen") { dismiss() }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var content: some View {
|
||
switch step {
|
||
case .intro:
|
||
card(icon: "arrow.triangle.2.circlepath",
|
||
title: "Einbaulage bestimmen",
|
||
text: "Sitzt der Sensor quer oder verdreht im Fahrzeug, meldet er "
|
||
+ "die Neigung vertauscht. Um das zu klären, wird er gleich "
|
||
+ "zweimal gekippt.\n\nBaue ihn dazu so ein oder halte ihn so, "
|
||
+ "wie er später sitzen soll. Er muss nicht angeschraubt sein – "
|
||
+ "nur die Ausrichtung muss stimmen.")
|
||
liveReadout
|
||
Button("Los geht’s") {
|
||
reference = live ?? .init(pitch: 0, roll: 0)
|
||
step = .tiltNose
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(live == nil)
|
||
|
||
case .tiltNose:
|
||
card(icon: "arrow.down.forward",
|
||
title: "Schritt 1 von 2: nach vorne kippen",
|
||
text: "Kippe den Sensor so, als würde das Fahrzeug **vorne "
|
||
+ "abwärts** stehen – die Front also nach unten.\n\n"
|
||
+ "Deutlich kippen, etwa eine Handbreit, und in dieser Lage "
|
||
+ "halten. Dann weiter.")
|
||
liveReadout
|
||
Button("Weiter") { finishNoseStep() }
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(live == nil)
|
||
|
||
case .settle:
|
||
card(icon: "equal.circle",
|
||
title: "Zurück in die Ruhelage",
|
||
text: "Stelle den Sensor wieder so hin wie am Anfang und lass "
|
||
+ "ihn kurz ruhen.\n\nVon hier aus wird die zweite Bewegung "
|
||
+ "gemessen.")
|
||
liveReadout
|
||
Button("Weiter") {
|
||
reference = live ?? reference
|
||
step = .tiltSide
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(live == nil)
|
||
|
||
case .tiltSide:
|
||
card(icon: "arrow.down.left",
|
||
title: "Schritt 2 von 2: nach links kippen",
|
||
text: "Kippe den Sensor jetzt so, als würde das Fahrzeug **nach "
|
||
+ "links** hängen – die linke Seite also nach unten.\n\n"
|
||
+ "Wieder deutlich kippen und in dieser Lage halten.")
|
||
liveReadout
|
||
HStack {
|
||
Button("Zurück") { step = .settle }
|
||
.buttonStyle(.bordered)
|
||
Button("Fertig") { finishSideStep() }
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(live == nil)
|
||
}
|
||
|
||
case .done(let orientation):
|
||
card(icon: "checkmark.circle.fill",
|
||
title: "Einbaulage erkannt",
|
||
text: "Ergebnis: **\(orientation.summary)**.\n\nDie Anzeige rechnet "
|
||
+ "die Werte des Sensors ab jetzt auf die Achsen des Fahrzeugs "
|
||
+ "um. Vergiss nicht, anschliessend im ebenen Stand zu "
|
||
+ "kalibrieren.")
|
||
Button("Übernehmen") { save(orientation) }
|
||
.buttonStyle(.borderedProminent)
|
||
|
||
case .failed(let failure):
|
||
card(icon: "exclamationmark.triangle.fill",
|
||
title: "Das hat nicht geklappt",
|
||
text: failure.message)
|
||
Button("Nochmal versuchen") { step = .intro }
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
}
|
||
|
||
private func card(icon: String, title: String, text: String) -> some View {
|
||
VStack(spacing: 12) {
|
||
Image(systemName: icon)
|
||
.font(.system(size: 44))
|
||
.foregroundStyle(.tint)
|
||
Text(title)
|
||
.font(.title3.weight(.semibold))
|
||
.multilineTextAlignment(.center)
|
||
Text(.init(text))
|
||
.multilineTextAlignment(.center)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding()
|
||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
|
||
}
|
||
|
||
/// Was der Sensor gerade meldet – ohne Umrechnung, denn die wird hier ja
|
||
/// erst bestimmt.
|
||
private var liveReadout: some View {
|
||
HStack(spacing: 16) {
|
||
value("Achse A", state.rawPitch)
|
||
value("Achse B", state.rawRoll)
|
||
}
|
||
}
|
||
|
||
private func value(_ title: String, _ reading: Double?) -> some View {
|
||
VStack(spacing: 4) {
|
||
Text(reading.map { String(format: "%.1f°", $0) } ?? "–")
|
||
.font(.title3.weight(.semibold).monospacedDigit())
|
||
Text(title)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 10)
|
||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
|
||
}
|
||
|
||
// MARK: - Ablauf
|
||
|
||
private func finishNoseStep() {
|
||
guard let live else { return }
|
||
noseChange = live - reference
|
||
// Der Bezug bleibt die Ruhelage. Von der gekippten Lage aus zu messen
|
||
// wäre falsch: die zweite Messung enthielte dann das Zurückkippen aus
|
||
// der ersten, und beide Achsen schlügen aus.
|
||
step = .settle
|
||
}
|
||
|
||
private func finishSideStep() {
|
||
guard let live, let nose = noseChange else { return }
|
||
let side = live - reference
|
||
switch OrientationDetection.orientation(nose: nose, side: side) {
|
||
case .success(let orientation): step = .done(orientation)
|
||
case .failure(let failure): step = .failed(failure)
|
||
}
|
||
}
|
||
|
||
private func save(_ orientation: SensorOrientation) {
|
||
var updated = device
|
||
updated.sensorOrientation = orientation
|
||
store.update(updated)
|
||
bluetooth.updateSensorOrientation(for: updated)
|
||
dismiss()
|
||
}
|
||
}
|