diff --git a/CamperMonitor/Bluetooth/BMSSession.swift b/CamperMonitor/Bluetooth/BMSSession.swift index dc7afa7..9fffffd 100644 --- a/CamperMonitor/Bluetooth/BMSSession.swift +++ b/CamperMonitor/Bluetooth/BMSSession.swift @@ -428,68 +428,95 @@ final class BMSSession: NSObject { } // MARK: - Auswertung - private func consume(_ data: Data) { lastResponse = data receivedByteCount += data.count buffer.append(contentsOf: [UInt8](data)) if buffer.count > 512 { buffer.removeFirst(buffer.count - 512) } - let (fridgeFrames, fridgeRemainder) = AlpicoolProtocol.extractFrames(from: buffer) - if !fridgeFrames.isEmpty { - buffer = fridgeRemainder - adopt(.alpicool) - for frame in fridgeFrames { alpicoolState.apply(frame) } - alpicoolState.zoneMode = fridgeZoneMode - onFridgeState?(alpicoolState) - publish(alpicoolState.snapshot(deviceID: deviceID, rssi: nil), - usable: alpicoolState.hasStatus) - return + // Steht der Dialekt fest, nur noch diesen prüfen. Bei jeder Antwort + // alle fünf Parser durchzugehen belastet die Funk-Queue ohne Nutzen. + switch dialect { + case .alpicool: consumeAlpicool(); return + case .wattCycle: consumeWattCycle(); return + case .jbd: consumeJBD(); return + case .dalyModbus: consumeDalyModbus(); return + case .dalyClassic: consumeDalyClassic(); return + case .unknown: break } - let (wattFrames, wattRemainder) = WattCycleProtocol.extractFrames(from: buffer) - if !wattFrames.isEmpty { - buffer = wattRemainder - adopt(.wattCycle) - for frame in wattFrames { wattCycleState.apply(frame) } - publish(wattCycleState.snapshot(deviceID: deviceID, rssi: nil), - usable: wattCycleState.hasUsableData) - return - } + if consumeAlpicool() { return } + if consumeWattCycle() { return } + if consumeJBD() { return } + if consumeDalyModbus() { return } + if consumeDalyClassic() { return } - // JBD als Nächstes: Start-, Endbyte und Prüfsumme machen den Rahmen eindeutig. - let (jbdFrames, jbdRemainder) = JBDProtocol.extractFrames(from: buffer) - if !jbdFrames.isEmpty { - buffer = jbdRemainder - adopt(.jbd) - for frame in jbdFrames { jbdState.apply(frame) } - publish(jbdState.snapshot(deviceID: deviceID, rssi: nil), usable: jbdState.hasUsableData) - return - } - - if let start = buffer.firstIndex(where: { $0 == 0xD2 }), - let registers = DalyProtocol.parseModbusResponse(Array(buffer[start...])) { - buffer.removeAll() - adopt(.dalyModbus) - dalyState.apply(registers: registers) - publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData) - return - } - - let (dalyFrames, dalyRemainder) = DalyProtocol.extractA5Frames(from: buffer) - if !dalyFrames.isEmpty { - buffer = dalyRemainder - adopt(.dalyClassic) - for frame in dalyFrames { dalyState.apply(frame) } - publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData) - return - } - - // Etwas kam an, ließ sich aber nicht zuordnen: für die Diagnose sichtbar - // machen, damit sich das Protokoll nachträglich bestimmen lässt. + // Etwas kam an, ließ sich aber nicht zuordnen: für die Diagnose + // sichtbar machen, damit sich das Protokoll bestimmen lässt. publishDiagnostics() } + @discardableResult + private func consumeAlpicool() -> Bool { + let (frames, remainder) = AlpicoolProtocol.extractFrames(from: buffer) + guard !frames.isEmpty else { return false } + buffer = remainder + adopt(.alpicool) + for frame in frames { alpicoolState.apply(frame) } + alpicoolState.zoneMode = fridgeZoneMode + onFridgeState?(alpicoolState) + publish(alpicoolState.snapshot(deviceID: deviceID, rssi: nil), + usable: alpicoolState.hasStatus) + return true + } + + @discardableResult + private func consumeWattCycle() -> Bool { + let (frames, remainder) = WattCycleProtocol.extractFrames(from: buffer) + guard !frames.isEmpty else { return false } + buffer = remainder + adopt(.wattCycle) + for frame in frames { wattCycleState.apply(frame) } + publish(wattCycleState.snapshot(deviceID: deviceID, rssi: nil), + usable: wattCycleState.hasUsableData) + return true + } + + /// JBD: Start-, Endbyte und Prüfsumme machen den Rahmen eindeutig. + @discardableResult + private func consumeJBD() -> Bool { + let (frames, remainder) = JBDProtocol.extractFrames(from: buffer) + guard !frames.isEmpty else { return false } + buffer = remainder + adopt(.jbd) + for frame in frames { jbdState.apply(frame) } + publish(jbdState.snapshot(deviceID: deviceID, rssi: nil), usable: jbdState.hasUsableData) + return true + } + + @discardableResult + private func consumeDalyModbus() -> Bool { + guard let start = buffer.firstIndex(where: { $0 == 0xD2 }), + let registers = DalyProtocol.parseModbusResponse(Array(buffer[start...])) + else { return false } + buffer.removeAll() + adopt(.dalyModbus) + dalyState.apply(registers: registers) + publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData) + return true + } + + @discardableResult + private func consumeDalyClassic() -> Bool { + let (frames, remainder) = DalyProtocol.extractA5Frames(from: buffer) + guard !frames.isEmpty else { return false } + buffer = remainder + adopt(.dalyClassic) + for frame in frames { dalyState.apply(frame) } + publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData) + return true + } + /// Erster verwertbarer Rahmen: Kandidat und Dialekt stehen fest, ab jetzt /// im normalen Takt abfragen. private func adopt(_ newDialect: Dialect) { diff --git a/CamperMonitor/Bluetooth/BluetoothManager.swift b/CamperMonitor/Bluetooth/BluetoothManager.swift index 18e3283..e901ed4 100644 --- a/CamperMonitor/Bluetooth/BluetoothManager.swift +++ b/CamperMonitor/Bluetooth/BluetoothManager.swift @@ -162,6 +162,7 @@ final class BluetoothManager: NSObject { let transport: DeviceTransport let role: DeviceRole let fridgeZoneMode: FridgeZoneMode + let sensorOrientation: SensorOrientation let victronKey: [UInt8]? } private var managed: [UUID: ManagedDevice] = [:] // Schlüssel: peripheralID @@ -170,6 +171,17 @@ final class BluetoothManager: NSObject { private var levelSessions: [UUID: LevelSession] = [:] private var connectedPeripherals: [UUID: CBPeripheral] = [:] private var reconnectTimer: DispatchSourceTimer? + + /// Frühester Zeitpunkt für den nächsten Verbindungsversuch je Gerät. + /// + /// Ohne diese Sperre entsteht ein Verbindungssturm: schlägt ein Versuch + /// fehl, ist der Eintrag wieder frei, und das nächste Advertisement löst + /// sofort den nächsten aus - bei aktivem Scan bis zu einmal je Sekunde. + /// Geräte quittieren jeden Versuch, Kühlboxen etwa mit einem Piepton. + private var nextConnectAttempt: [UUID: Date] = [:] + private var connectFailures: [UUID: Int] = [:] + private let firstRetryDelay: TimeInterval = 5 + private let longestRetryDelay: TimeInterval = 60 private var discoveryFlushTimer: DispatchSourceTimer? private var discovering = false @@ -245,6 +257,7 @@ final class BluetoothManager: NSObject { transport: device.role.transport, role: device.role, fridgeZoneMode: device.fridgeZoneMode, + sensorOrientation: device.sensorOrientation, victronKey: store.victronKeyBytes(for: device.id)) } let known = Set(devices.map(\.id)) @@ -309,6 +322,15 @@ final class BluetoothManager: NSObject { } } + /// Nach einer Änderung der Einbaulage aufrufen. + func updateSensorOrientation(for device: ConfiguredDevice) { + let deviceID = device.id, orientation = device.sensorOrientation + queue.async { + guard let session = self.levelSession(for: deviceID) else { return } + session.orientation = orientation + } + } + /// Setzt die aktuelle Lage des Fahrzeugs als neue Null. func calibrateLevel(for deviceID: UUID) { queue.async { self.levelSession(for: deviceID)?.calibrate() } @@ -346,12 +368,15 @@ final class BluetoothManager: NSObject { levelSessions[peripheralID] = nil disconnect(peripheralID) } - // Zoneneinstellung an bestehende Sitzungen weiterreichen. + // Einstellungen an bestehende Sitzungen weiterreichen. for device in devices { bmsSessions[device.peripheralID]?.fridgeZoneMode = device.fridgeZoneMode + levelSessions[device.peripheralID]?.orientation = device.sensorOrientation } lastHandledAdvertisement.removeAll() lastDiagnosticsUpdate.removeAll() + nextConnectAttempt.removeAll() + connectFailures.removeAll() startScanning() } @@ -416,6 +441,11 @@ final class BluetoothManager: NSObject { existing.state == .connected || existing.state == .connecting { return } + // Nach einem Fehlschlag eine Weile Ruhe geben, sonst wird das Gerät + // im Sekundentakt angeklopft. + if let earliest = nextConnectAttempt[device.peripheralID], Date() < earliest { + return + } guard let peripheral = central? .retrievePeripherals(withIdentifiers: [device.peripheralID]).first else { publish { self.linkStates[device.id] = .searching } @@ -426,6 +456,21 @@ final class BluetoothManager: NSObject { central?.connect(peripheral, options: nil) } + /// Sperrt weitere Versuche für eine Weile. Der Abstand wächst mit jedem + /// Fehlschlag, damit ein dauerhaft unerreichbares Gerät nicht endlos + /// angeklopft wird. + private func backOff(_ peripheralID: UUID) { + let failures = (connectFailures[peripheralID] ?? 0) + 1 + connectFailures[peripheralID] = failures + let delay = min(firstRetryDelay * pow(2, Double(failures - 1)), longestRetryDelay) + nextConnectAttempt[peripheralID] = Date().addingTimeInterval(delay) + } + + private func clearBackOff(_ peripheralID: UUID) { + connectFailures[peripheralID] = nil + nextConnectAttempt[peripheralID] = nil + } + /// Verbindungen fallen im Fahrzeug regelmässig weg – deshalb regelmässig /// nachfassen statt nur auf das Disconnect-Ereignis zu reagieren. private func scheduleReconnects() { @@ -623,6 +668,7 @@ extension BluetoothManager: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { guard let device = managed[peripheral.identifier] else { return } connectedPeripherals[peripheral.identifier] = peripheral + clearBackOff(peripheral.identifier) if device.role == .leveling { let session = LevelSession( @@ -637,6 +683,7 @@ extension BluetoothManager: CBCentralManagerDelegate { self?.publish { self?.levelStates[device.id] = state } } ) + session.orientation = device.sensorOrientation levelSessions[peripheral.identifier] = session session.start() return @@ -670,6 +717,7 @@ extension BluetoothManager: CBCentralManagerDelegate { publish { self.linkStates[device.id] = .failed(message) } } connectedPeripherals[peripheral.identifier] = nil + backOff(peripheral.identifier) } func centralManager(_ central: CBCentralManager, @@ -683,9 +731,10 @@ extension BluetoothManager: CBCentralManagerDelegate { if let device = managed[peripheral.identifier] { publish { self.linkStates[device.id] = .searching } - // Direkt wieder anfragen; iOS stellt die Verbindung her, sobald das - // Gerät wieder in Reichweite ist. - connectIfNeeded(device) + // Nicht sofort wieder anklopfen. Trennt ein Gerät von sich aus - + // etwa weil eine andere App verbunden ist -, entstünde sonst ein + // Wechselspiel aus Verbinden und Trennen. + backOff(peripheral.identifier) } } } diff --git a/CamperMonitor/Bluetooth/LevelSession.swift b/CamperMonitor/Bluetooth/LevelSession.swift index 6b010b4..c969d46 100644 --- a/CamperMonitor/Bluetooth/LevelSession.swift +++ b/CamperMonitor/Bluetooth/LevelSession.swift @@ -32,6 +32,10 @@ final class LevelSession: NSObject { private var calibrateCharacteristic: CBCharacteristic? private var state = LevelState() + /// Aus den Geräteeinstellungen; rechnet Sensor- in Fahrzeugachsen um. + var orientation = SensorOrientation.identity { + didSet { applyOrientation() } + } private var pollTimer: DispatchSourceTimer? /// Nur nötig, solange das Gerät die Werte nicht von selbst schickt. private var needsPolling = false @@ -136,6 +140,13 @@ final class LevelSession: NSObject { pollTimer = timer } + /// Rechnet die Rohwerte in Fahrzeugachsen um. + private func applyOrientation() { + let corrected = orientation.apply(pitch: state.rawPitch, roll: state.rawRoll) + state.pitch = corrected.pitch + state.roll = corrected.roll + } + private func publish() { guard state.hasReading else { return } onStateChange(.live) @@ -212,9 +223,11 @@ extension LevelSession: CBPeripheralDelegate { switch characteristic.uuid { case Self.pitchUUID: - state.pitch = VanAlignProtocol.angle(from: value) + state.rawPitch = VanAlignProtocol.angle(from: value) + applyOrientation() case Self.rollUUID: - state.roll = VanAlignProtocol.angle(from: value) + state.rawRoll = VanAlignProtocol.angle(from: value) + applyOrientation() case Self.offsetsUUID: if let offsets = VanAlignProtocol.offsets(from: value) { state.pitchOffset = offsets.pitch diff --git a/CamperMonitor/Bluetooth/VanAlignProtocol.swift b/CamperMonitor/Bluetooth/VanAlignProtocol.swift index c12783f..fff1487 100644 --- a/CamperMonitor/Bluetooth/VanAlignProtocol.swift +++ b/CamperMonitor/Bluetooth/VanAlignProtocol.swift @@ -53,9 +53,13 @@ enum VanAlignProtocol { /// Zustand des Neigungsmessers. struct LevelState: Equatable { /// Längsneigung: positiv bedeutet, das Heck steht höher als die Front. + /// Bereits auf die Einbaulage des Sensors umgerechnet. var pitch: Double? /// Querneigung: positiv bedeutet, die rechte Seite steht höher. var roll: Double? + /// Wie der Sensor selbst meldet – nötig, um die Einbaulage zu bestimmen. + var rawPitch: Double? + var rawRoll: Double? var pitchOffset: Double? var rollOffset: Double? diff --git a/CamperMonitor/Models/ConfiguredDevice.swift b/CamperMonitor/Models/ConfiguredDevice.swift index 4e513f3..b52b1c9 100644 --- a/CamperMonitor/Models/ConfiguredDevice.swift +++ b/CamperMonitor/Models/ConfiguredDevice.swift @@ -84,6 +84,8 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable { var advertisedName: String? /// Nur für Kühlboxen. var fridgeZoneMode: FridgeZoneMode = .automatic + /// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt. + var sensorOrientation = SensorOrientation.identity init(id: UUID = UUID(), name: String, @@ -112,5 +114,7 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable { advertisedName = try container.decodeIfPresent(String.self, forKey: .advertisedName) fridgeZoneMode = try container.decodeIfPresent(FridgeZoneMode.self, forKey: .fridgeZoneMode) ?? .automatic + sensorOrientation = try container.decodeIfPresent(SensorOrientation.self, + forKey: .sensorOrientation) ?? .identity } } diff --git a/CamperMonitor/Models/SensorOrientation.swift b/CamperMonitor/Models/SensorOrientation.swift new file mode 100644 index 0000000..1da0b63 --- /dev/null +++ b/CamperMonitor/Models/SensorOrientation.swift @@ -0,0 +1,128 @@ +import Foundation + +/// Wie der Neigungsmesser im Fahrzeug sitzt. +/// +/// Der Sensor kann quer eingebaut, gedreht oder kopfüber montiert sein. Dann +/// stimmen seine Achsen nicht mit denen des Fahrzeugs überein: was er als +/// Längsneigung meldet, ist womöglich die Querneigung, und Vorzeichen können +/// vertauscht sein. +/// +/// Statt das raten zu lassen, ermittelt der Einrichtungsassistent die Lage +/// durch zwei definierte Kippbewegungen. +struct SensorOrientation: Codable, Equatable, Hashable, Sendable { + + /// Welche Achse des Sensors die Längsneigung des Fahrzeugs liefert. + enum Source: String, Codable, Hashable, Sendable { + case pitch + case roll + } + + /// Woher die Längsneigung kommt. Die Querneigung kommt aus der jeweils + /// anderen Achse. + var longitudinalSource: Source = .pitch + var invertLongitudinal = false + var invertLateral = false + + static let identity = SensorOrientation() + + var isIdentity: Bool { self == .identity } + + /// Rechnet Sensorwerte in Fahrzeugwerte um. + func apply(pitch: Double?, roll: Double?) -> (pitch: Double?, roll: Double?) { + let longitudinal = longitudinalSource == .pitch ? pitch : roll + let lateral = longitudinalSource == .pitch ? roll : pitch + return (longitudinal.map { invertLongitudinal ? -$0 : $0 }, + lateral.map { invertLateral ? -$0 : $0 }) + } + + var summary: String { + if isIdentity { return "Achsen unverändert" } + var parts: [String] = [] + if longitudinalSource == .roll { parts.append("Achsen getauscht") } + if invertLongitudinal { parts.append("längs umgekehrt") } + if invertLateral { parts.append("quer umgekehrt") } + return parts.joined(separator: ", ") + } +} + +/// Wertet die Kippbewegungen des Einrichtungsassistenten aus. +/// +/// Zweimal wird gekippt: einmal die Front nach unten, einmal die linke Seite +/// nach unten. Welche Sensorachse sich dabei jeweils bewegt und in welche +/// Richtung, ergibt die Einbaulage. +enum OrientationDetection { + + /// So weit muss gekippt werden, damit die Bewegung eindeutig ist. + /// Darunter wäre nicht zu unterscheiden, ob überhaupt gekippt wurde. + static let minimumTilt = 5.0 + + struct Reading: Equatable { + let pitch: Double + let roll: Double + + static func - (lhs: Reading, rhs: Reading) -> Reading { + Reading(pitch: lhs.pitch - rhs.pitch, roll: lhs.roll - rhs.roll) + } + } + + enum Failure: Error, Equatable { + /// Es wurde zu wenig oder gar nicht gekippt. + case tooLittleMovement + /// Beide Achsen haben sich ähnlich stark bewegt – dann lässt sich + /// nicht sagen, welche gemeint war. + case ambiguous + + var message: String { + switch self { + case .tooLittleMovement: + return "Zu wenig Bewegung. Deutlicher kippen, mindestens eine " + + "Handbreit." + case .ambiguous: + return "Es wurde in beide Richtungen gleichzeitig gekippt. " + + "Möglichst nur um die eine Achse kippen." + } + } + } + + /// Ermittelt aus beiden Kippbewegungen die Einbaulage. + /// + /// - Parameters: + /// - nose: Änderung beim Kippen der Front nach unten. + /// - side: Änderung beim Kippen der linken Seite nach unten. + static func orientation(nose: Reading, + side: Reading) -> Result { + // Welche Achse reagiert auf das Kippen nach vorne? + guard let longitudinal = dominantAxis(nose) else { + return .failure(max(abs(nose.pitch), abs(nose.roll)) < minimumTilt + ? .tooLittleMovement : .ambiguous) + } + guard let lateral = dominantAxis(side) else { + return .failure(max(abs(side.pitch), abs(side.roll)) < minimumTilt + ? .tooLittleMovement : .ambiguous) + } + // Beide Bewegungen dürfen nicht auf dieselbe Achse zeigen – dann wurde + // zweimal dasselbe gekippt. + guard longitudinal.source != lateral.source else { return .failure(.ambiguous) } + + var orientation = SensorOrientation() + orientation.longitudinalSource = longitudinal.source + // Front nach unten heisst: das Heck steht höher, die Längsneigung des + // Fahrzeugs ist also positiv. + orientation.invertLongitudinal = longitudinal.change < 0 + // Linke Seite nach unten heisst: rechts steht höher, die Querneigung + // ist positiv. + orientation.invertLateral = lateral.change < 0 + return .success(orientation) + } + + /// Die Achse, die sich deutlich stärker bewegt hat. + private static func dominantAxis(_ change: Reading) + -> (source: SensorOrientation.Source, change: Double)? { + let byPitch = abs(change.pitch), byRoll = abs(change.roll) + guard max(byPitch, byRoll) >= minimumTilt else { return nil } + // Die schwächere Achse muss deutlich zurückstehen, sonst wurde schräg + // gekippt und die Zuordnung wäre Zufall. + guard max(byPitch, byRoll) >= 2 * min(byPitch, byRoll) else { return nil } + return byPitch > byRoll ? (.pitch, change.pitch) : (.roll, change.roll) + } +} diff --git a/CamperMonitor/Views/LevelView.swift b/CamperMonitor/Views/LevelView.swift index 81dc9bb..0baa3a8 100644 --- a/CamperMonitor/Views/LevelView.swift +++ b/CamperMonitor/Views/LevelView.swift @@ -110,6 +110,7 @@ struct LevelControls: View { @State private var showResetConfirmation = false @State private var showAssistant = false @AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble + @State private var showSensorSetup = false var body: some View { Section { @@ -164,6 +165,24 @@ struct LevelControls: View { .disabled(!state.hasReading) } + Section { + Button { + showSensorSetup = true + } label: { + LabeledContent { + Text(device.sensorOrientation.summary) + .font(.caption) + } label: { + Label("Einbaulage", systemImage: "arrow.triangle.2.circlepath") + } + } + } header: { + Text("Sensor") + } footer: { + Text("Sitzt der Sensor quer oder verdreht, meldet er längs und quer " + + "vertauscht. Der Assistent klärt das durch zweimaliges Kippen.") + } + Section { Button("Auf aktuelle Lage kalibrieren", systemImage: "scope") { bluetooth.calibrateLevel(for: device.id) @@ -190,6 +209,9 @@ struct LevelControls: View { + "Ob bereits kalibriert wurde, meldet dieses Gerät nicht zurück.") } } + .sheet(isPresented: $showSensorSetup) { + SensorSetupView(device: device) + } .fullScreenCover(isPresented: $showAssistant) { AlignmentAssistantView(device: device, profile: store.activeProfile) } diff --git a/CamperMonitor/Views/SensorSetupView.swift b/CamperMonitor/Views/SensorSetupView.swift new file mode 100644 index 0000000..f802b1e --- /dev/null +++ b/CamperMonitor/Views/SensorSetupView.swift @@ -0,0 +1,185 @@ +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 tiltSide + case done(SensorOrientation) + case failed(OrientationDetection.Failure) + } + + @State private var step: Step = .intro + /// Ruhelage vor der jeweiligen Kippbewegung. + @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 .tiltSide: + card(icon: "arrow.down.left", + title: "Schritt 2 von 2: nach links kippen", + text: "Zurück in die Ausgangslage, dann den Sensor so kippen, als " + + "würde das Fahrzeug **nach links** hängen – die linke Seite " + + "also nach unten.\n\nWieder deutlich kippen und halten.") + liveReadout + HStack { + Button("Zurück") { step = .tiltNose } + .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 + reference = live + step = .tiltSide + } + + private func finishSideStep() { + guard let live, let nose = noseChange else { return } + // Der Bezug ist die Lage aus Schritt 1, deshalb wird von dort gemessen. + 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() + } +} diff --git a/CamperMonitor/logo.png b/CamperMonitor/logo.png new file mode 100644 index 0000000..b2754e3 Binary files /dev/null and b/CamperMonitor/logo.png differ diff --git a/README.md b/README.md index 4865809..912793e 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,13 @@ liegen als Schablonen im Asset-Katalog und werden je nach Abweichung eingefärbt; die Helligkeit des Originals wurde dafür in Deckkraft übersetzt, damit Fenster und Konturen beim Einfärben erhalten bleiben. -**Vor der ersten Nutzung kalibrieren:** Fahrzeug eben stellen, dann *Auf +**Vor der ersten Nutzung die Einbaulage bestimmen.** Sitzt der Sensor quer, +gedreht oder kopfüber, meldet er längs und quer vertauscht oder mit falschem +Vorzeichen. Der Assistent unter *Sensor → Einbaulage* klärt das durch zwei +Kippbewegungen: einmal die Front nach unten, einmal die linke Seite. Aus der +Reaktion ergibt sich die Zuordnung – geraten wird nichts. + +**Danach kalibrieren:** Fahrzeug eben stellen, dann *Auf aktuelle Lage kalibrieren*. Ohne das zeigt die Anzeige die Lage des Sensors, nicht die des Fahrzeugs – je nachdem, wie schief er eingebaut ist. @@ -233,6 +239,7 @@ CamperMonitor/ ├── Models/ │ ├── Profile.swift Fahrzeug samt Maßen │ ├── AlignmentAssistant.swift Verlauf, Tendenz und Keilberechnung +│ ├── SensorOrientation.swift Einbaulage und ihre Erkennung │ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart │ ├── DeviceSnapshot.swift Messwerte in Anzeigeform │ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes @@ -262,6 +269,7 @@ CamperMonitor/ ├── AlignmentAssistantView.swift Ausrichtungs-Assistent fürs Rangieren ├── VehicleTiltView.swift Neigung am Fahrzeug dargestellt ├── SettingsView.swift App-Einstellungen + ├── SensorSetupView.swift Assistent für die Einbaulage └── AddDeviceView.swift Scannen und Einrichten ``` diff --git a/Tests/main.swift b/Tests/main.swift index 2ec4703..7170f92 100644 --- a/Tests/main.swift +++ b/Tests/main.swift @@ -741,5 +741,90 @@ checkEqual("innerhalb der Toleranz kein Keil", checkEqual("ohne Maß kein Keil", LevelingWedge.across(roll: 3.0, trackWidth: 0) == nil, true) +// MARK: 12 – Einbaulage des Neigungsmessers +print("\nEinbaulage") + +typealias Reading = OrientationDetection.Reading +func detect(nose: Reading, side: Reading) -> Result { + OrientationDetection.orientation(nose: nose, side: side) +} + +// Gerade eingebaut: Kippen nach vorne bewegt die erste Achse ins Positive, +// Kippen nach links die zweite. +if case .success(let straight) = detect(nose: Reading(pitch: 12, roll: 0), + side: Reading(pitch: 0, roll: 9)) { + checkEqual("gerade: Längsneigung von der ersten Achse", + straight.longitudinalSource, .pitch) + checkEqual("gerade: längs nicht umgekehrt", straight.invertLongitudinal, false) + checkEqual("gerade: quer nicht umgekehrt", straight.invertLateral, false) + checkEqual("gerade ist die unveränderte Lage", straight.isIdentity, true) + checkEqual("Beschreibung", straight.summary, "Achsen unverändert") +} else { + check("gerade eingebaut wird erkannt", false) +} + +// Um 180 Grad gedreht: beide Achsen kehren sich um. +if case .success(let turned) = detect(nose: Reading(pitch: -12, roll: 0), + side: Reading(pitch: 0, roll: -9)) { + checkEqual("gedreht: Achse bleibt", turned.longitudinalSource, .pitch) + checkEqual("gedreht: längs umgekehrt", turned.invertLongitudinal, true) + checkEqual("gedreht: quer umgekehrt", turned.invertLateral, true) +} else { + check("um 180 Grad gedreht wird erkannt", false) +} + +// Quer eingebaut: die Achsen sind vertauscht. +if case .success(let sideways) = detect(nose: Reading(pitch: 0, roll: 11), + side: Reading(pitch: -8, roll: 0)) { + checkEqual("quer: Längsneigung von der zweiten Achse", + sideways.longitudinalSource, .roll) + checkEqual("quer: längs nicht umgekehrt", sideways.invertLongitudinal, false) + checkEqual("quer: quer umgekehrt", sideways.invertLateral, true) + checkEqual("Beschreibung nennt den Tausch", + sideways.summary.contains("getauscht"), true) +} else { + check("quer eingebaut wird erkannt", false) +} + +// Zu wenig gekippt. +if case .failure(let failure) = detect(nose: Reading(pitch: 2, roll: 0), + side: Reading(pitch: 0, roll: 9)) { + checkEqual("zu wenig Bewegung wird gemeldet", failure, .tooLittleMovement) +} else { + check("zu wenig Bewegung wird abgefangen", false) +} + +// Schräg gekippt: beide Achsen ähnlich stark, keine Zuordnung möglich. +if case .failure(let failure) = detect(nose: Reading(pitch: 9, roll: 8), + side: Reading(pitch: 0, roll: 9)) { + checkEqual("schräges Kippen wird gemeldet", failure, .ambiguous) +} else { + check("schräges Kippen wird abgefangen", false) +} + +// Zweimal um dieselbe Achse gekippt ergibt keine Zuordnung. +if case .failure(let failure) = detect(nose: Reading(pitch: 10, roll: 0), + side: Reading(pitch: 9, roll: 0)) { + checkEqual("zweimal dieselbe Achse wird gemeldet", failure, .ambiguous) +} else { + check("zweimal dieselbe Achse wird abgefangen", false) +} + +// Die Umrechnung selbst +var swapped = SensorOrientation() +swapped.longitudinalSource = .roll +swapped.invertLateral = true +let converted = swapped.apply(pitch: 3, roll: -2) +checkEqual("umgerechnet: längs kommt aus der zweiten Achse", converted.pitch, -2) +checkEqual("umgerechnet: quer ist umgekehrt", converted.roll, -3) + +let untouched = SensorOrientation.identity.apply(pitch: 1.5, roll: -0.5) +checkEqual("unveränderte Lage lässt längs stehen", untouched.pitch, 1.5) +checkEqual("unveränderte Lage lässt quer stehen", untouched.roll, -0.5) + +let missing = SensorOrientation.identity.apply(pitch: nil, roll: 2) +checkEqual("fehlende Werte bleiben leer", missing.pitch == nil, true) +checkEqual("vorhandene Werte kommen durch", missing.roll, 2) + 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 9b7cf54..b4d227b 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -18,6 +18,7 @@ swiftc -O -o "$OUT/tests" \ CamperMonitor/Models/ConfiguredDevice.swift \ CamperMonitor/Models/Profile.swift \ CamperMonitor/Models/AlignmentAssistant.swift \ + CamperMonitor/Models/SensorOrientation.swift \ CamperMonitor/Models/VictronCodes.swift \ CamperMonitor/Store/KeychainStore.swift \ Tests/main.swift