diff --git a/CamperMonitor/Bluetooth/BMSSession.swift b/CamperMonitor/Bluetooth/BMSSession.swift index 9fffffd..0d6c169 100644 --- a/CamperMonitor/Bluetooth/BMSSession.swift +++ b/CamperMonitor/Bluetooth/BMSSession.swift @@ -96,6 +96,12 @@ final class BMSSession: NSObject { private var lastResponse: Data? private var lastCommand: Data? private var lastCommandAt: Date? + /// Stellbefehle, die kamen, bevor der Kanal stand. Sie jetzt schon zu + /// senden hiesse, sie an einen womöglich falschen Kandidaten zu schicken; + /// sie fallen zu lassen hiesse, ein Tippen zu verschlucken. + private var waitingControls: [(packet: Data, queuedAt: Date)] = [] + /// So lange darf ein Befehl warten, bevor er verfällt. + private let controlLifetime: TimeInterval = 30 private var receivedByteCount = 0 private var sentFrameCount = 0 private var gattSummary: [String] = [] @@ -291,6 +297,7 @@ final class BMSSession: NSObject { private func beginPolling() { pollTimer?.cancel() + flushWaitingControls() poll() let interval = dialect == .unknown ? searchInterval : pollInterval let timer = DispatchSource.makeTimerSource(queue: queue) @@ -419,6 +426,18 @@ final class BMSSession: NSObject { func sendControl(_ packet: Data) { lastCommand = packet lastCommandAt = Date() + + // Erst wenn der Dialekt steht, ist auch der richtige Kanal bekannt. + guard dialect == .alpicool, currentEndpoint != nil, + peripheral.state == .connected else { + waitingControls.append((packet, Date())) + if waitingControls.count > 4 { + waitingControls.removeFirst(waitingControls.count - 4) + } + publishDiagnostics() + return + } + send(packet) // Genug Abstand, damit ein aufgeteiltes Paket vollständig draußen ist. queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in @@ -427,6 +446,20 @@ final class BMSSession: NSObject { } } + /// Schickt raus, was während des Verbindungsaufbaus aufgelaufen ist. + private func flushWaitingControls() { + guard dialect == .alpicool, !waitingControls.isEmpty else { return } + let due = waitingControls.filter { + Date().timeIntervalSince($0.queuedAt) < controlLifetime + } + waitingControls.removeAll() + for (index, entry) in due.enumerated() { + queue.asyncAfter(deadline: .now() + Double(index) * 0.4) { [weak self] in + self?.sendControl(entry.packet) + } + } + } + // MARK: - Auswertung private func consume(_ data: Data) { lastResponse = data diff --git a/CamperMonitor/Bluetooth/BluetoothManager.swift b/CamperMonitor/Bluetooth/BluetoothManager.swift index e901ed4..92384a7 100644 --- a/CamperMonitor/Bluetooth/BluetoothManager.swift +++ b/CamperMonitor/Bluetooth/BluetoothManager.swift @@ -182,6 +182,24 @@ final class BluetoothManager: NSObject { private var connectFailures: [UUID: Int] = [:] private let firstRetryDelay: TimeInterval = 5 private let longestRetryDelay: TimeInterval = 60 + /// Nach dem Abbruch einer Verbindung, die stand, wird zügig nachgefasst - + /// das ist der Normalfall im Fahrzeug und kein Fehler. + private let quickRetryDelay: TimeInterval = 2 + /// Ab dieser Dauer gilt eine Verbindung als zustandegekommen. Was sofort + /// wieder abbricht, zählt als Fehlschlag. + private let stableConnection: TimeInterval = 3 + private var connectedSince: [UUID: Date] = [:] + + /// Stellbefehle, die kamen, während die Verbindung weg war. Ohne das + /// verschwindet ein Tippen lautlos: die Sitzung ist dann nicht da, und der + /// Befehl fällt ins Leere. + private var pendingControls: [UUID: [(packet: Data, queuedAt: Date)]] = [:] + /// So lange darf ein aufgehobener Befehl warten. Was älter ist, wird + /// verworfen - ein verspätetes Ausschalten würde nur überraschen. + private let controlLifetime: TimeInterval = 30 + /// Letzter bekannter Kühlbox-Zustand, auch über eine Trennung hinweg. Der + /// Einstellungsblock wird daraus gebaut. + private var fridgeStateCache: [UUID: AlpicoolState] = [:] private var discoveryFlushTimer: DispatchSourceTimer? private var discovering = false @@ -293,10 +311,7 @@ final class BluetoothManager: NSObject { } func setFridgeTarget(_ celsius: Int, zone: AlpicoolState.Zone, for deviceID: UUID) { - queue.async { - self.session(for: deviceID)? - .sendControl(AlpicoolState.setTarget(zone: zone, to: celsius)) - } + sendFridgeSettings(for: deviceID) { _ in AlpicoolState.setTarget(zone: zone, to: celsius) } } func setFridgePower(_ on: Bool, for deviceID: UUID) { @@ -313,12 +328,44 @@ final class BluetoothManager: NSObject { /// Der Einstellungsblock wird aus dem zuletzt empfangenen Zustand gebaut – /// und zwar auf der Funk-Queue, wo dieser Zustand lebt. + /// + /// Fehlt die Verbindung gerade, wird der Befehl aufgehoben und ein Versuch + /// angestossen. Ein Tippen darf nicht daran scheitern, dass die Box sich + /// zwei Sekunden vorher abgemeldet hat. private func sendFridgeSettings(for deviceID: UUID, build: @escaping (AlpicoolState) -> Data?) { queue.async { - guard let session = self.session(for: deviceID), - let packet = build(session.alpicoolState) else { return } - session.sendControl(packet) + guard let device = self.managed.values.first(where: { $0.id == deviceID }) + else { return } + + if let session = self.bmsSessions[device.peripheralID] { + if let packet = build(session.alpicoolState) { session.sendControl(packet) } + return + } + + guard let known = self.fridgeStateCache[device.peripheralID], + let packet = build(known) else { return } + var waiting = self.pendingControls[device.peripheralID] ?? [] + waiting.append((packet, Date())) + // Mehr als eine Handvoll ergibt keinen Sinn – wer mehrfach tippt, + // meint den letzten Stand. + if waiting.count > 4 { waiting.removeFirst(waiting.count - 4) } + self.pendingControls[device.peripheralID] = waiting + + // Der Wunsch des Benutzers hebt die Wartesperre auf. + self.clearBackOff(device.peripheralID) + self.connectIfNeeded(device) + } + } + + /// Gibt weiter, was während der Trennung aufgelaufen ist. + private func flushPendingControls(_ peripheralID: UUID, to session: BMSSession) { + guard let waiting = pendingControls.removeValue(forKey: peripheralID) else { return } + let fresh = waiting.filter { Date().timeIntervalSince($0.queuedAt) < controlLifetime } + for (index, entry) in fresh.enumerated() { + queue.asyncAfter(deadline: .now() + Double(index) * 0.4) { [weak session] in + session?.sendControl(entry.packet) + } } } @@ -377,6 +424,7 @@ final class BluetoothManager: NSObject { lastDiagnosticsUpdate.removeAll() nextConnectAttempt.removeAll() connectFailures.removeAll() + connectedSince.removeAll() startScanning() } @@ -416,6 +464,8 @@ final class BluetoothManager: NSObject { bmsSessions.removeAll() levelSessions.removeAll() connectedPeripherals.removeAll() + connectedSince.removeAll() + pendingControls.removeAll() } private func restartScan() { @@ -668,7 +718,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) + connectedSince[peripheral.identifier] = Date() if device.role == .leveling { let session = LevelSession( @@ -702,11 +752,13 @@ extension BluetoothManager: CBCentralManagerDelegate { } ) session.onFridgeState = { [weak self] state in + self?.fridgeStateCache[peripheral.identifier] = state self?.publish { self?.fridgeStates[device.id] = state } } session.fridgeZoneMode = device.fridgeZoneMode bmsSessions[peripheral.identifier] = session session.start() + flushPendingControls(peripheral.identifier, to: session) } func centralManager(_ central: CBCentralManager, @@ -729,12 +781,24 @@ extension BluetoothManager: CBCentralManagerDelegate { levelSessions[peripheral.identifier] = nil connectedPeripherals[peripheral.identifier] = nil + let lifetime = connectedSince.removeValue(forKey: peripheral.identifier) + .map { Date().timeIntervalSince($0) } ?? 0 + if let device = managed[peripheral.identifier] { publish { self.linkStates[device.id] = .searching } - // 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) + if lifetime >= stableConnection { + // Die Verbindung stand und ist weggefallen - im Fahrzeug der + // Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das + // Gerät minutenlang nicht bedienbar. + clearBackOff(peripheral.identifier) + nextConnectAttempt[peripheral.identifier] = + Date().addingTimeInterval(quickRetryDelay) + } else { + // Sofort wieder abgebrochen: das zählt wie ein Fehlschlag. So + // entsteht kein Wechselspiel aus Verbinden und Trennen, wenn + // etwa eine andere App das Gerät belegt. + backOff(peripheral.identifier) + } } } } diff --git a/CamperMonitor/Models/SensorOrientation.swift b/CamperMonitor/Models/SensorOrientation.swift index 1da0b63..2c63043 100644 --- a/CamperMonitor/Models/SensorOrientation.swift +++ b/CamperMonitor/Models/SensorOrientation.swift @@ -56,6 +56,9 @@ enum OrientationDetection { /// Darunter wäre nicht zu unterscheiden, ob überhaupt gekippt wurde. static let minimumTilt = 5.0 + /// Soviel deutlicher muss die gewinnende Deutung sein als die andere. + static let ambiguityMargin = 1.3 + struct Reading: Equatable { let pitch: Double let roll: Double @@ -78,51 +81,55 @@ enum OrientationDetection { 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." + return "Beide Schritte haben dieselbe Achse bewegt. Im ersten " + + "Schritt nach vorne kippen, im zweiten zur Seite." } } } /// Ermittelt aus beiden Kippbewegungen die Einbaulage. /// + /// Beide Bewegungen werden gemeinsam beurteilt. Kippt man von Hand zur + /// Seite, geht die andere Achse fast immer ein Stück mit – jede Bewegung + /// für sich betrachtet sähe das nach schrägem Kippen aus. Im Paar ist die + /// Zuordnung trotzdem eindeutig: es gewinnt die Deutung, die beide + /// Bewegungen zusammen am besten erklärt. + /// /// - 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 max(abs(nose.pitch), abs(nose.roll)) >= minimumTilt, + max(abs(side.pitch), abs(side.roll)) >= minimumTilt else { + return .failure(.tooLittleMovement) } - 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) } + + // Zwei mögliche Deutungen, bewertet danach, wieviel Bewegung sie + // erklären: die erste Achse längs und die zweite quer – oder umgekehrt. + let pitchIsLongitudinal = abs(nose.pitch) + abs(side.roll) + let rollIsLongitudinal = abs(nose.roll) + abs(side.pitch) + let winner = max(pitchIsLongitudinal, rollIsLongitudinal) + let loser = min(pitchIsLongitudinal, rollIsLongitudinal) + // Liegen beide Deutungen dicht beieinander, ist die Zuordnung wirklich + // nicht zu treffen – etwa wenn zweimal um dieselbe Achse gekippt wurde. + guard winner >= ambiguityMargin * loser else { return .failure(.ambiguous) } var orientation = SensorOrientation() - orientation.longitudinalSource = longitudinal.source + orientation.longitudinalSource = pitchIsLongitudinal > rollIsLongitudinal ? .pitch : .roll + let longitudinal = orientation.longitudinalSource == .pitch ? nose.pitch : nose.roll + let lateral = orientation.longitudinalSource == .pitch ? side.roll : side.pitch + // Die zugeordnete Achse muss in ihrem Schritt auch deutlich gewandert + // sein, sonst stünde das Vorzeichen auf wackligem Grund. + guard abs(longitudinal) >= minimumTilt, abs(lateral) >= minimumTilt else { + return .failure(.tooLittleMovement) + } // Front nach unten heisst: das Heck steht höher, die Längsneigung des // Fahrzeugs ist also positiv. - orientation.invertLongitudinal = longitudinal.change < 0 + orientation.invertLongitudinal = longitudinal < 0 // Linke Seite nach unten heisst: rechts steht höher, die Querneigung // ist positiv. - orientation.invertLateral = lateral.change < 0 + orientation.invertLateral = lateral < 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/FridgeControls.swift b/CamperMonitor/Views/FridgeControls.swift index d29da4d..8bb4d8f 100644 --- a/CamperMonitor/Views/FridgeControls.swift +++ b/CamperMonitor/Views/FridgeControls.swift @@ -45,11 +45,24 @@ struct FridgeControls: View { } header: { Text("Steuerung") } footer: { - Text("Änderungen gehen direkt an die Box. Der angezeigte Stand kommt " - + "aus ihrer Antwort, nicht aus der Eingabe.") + Text(footerText) } } + /// Ob die Box gerade erreichbar ist. Ist sie es nicht, wird ein Befehl + /// aufgehoben statt verworfen – das gehört gesagt, sonst sieht es aus, als + /// hätte das Tippen nichts bewirkt. + private var isLinked: Bool { bluetooth.linkStates[device.id] == .live } + + private var footerText: String { + guard isLinked else { + return "Die Box ist gerade nicht verbunden. Die Änderung wird " + + "gemerkt und geht raus, sobald sie wieder erreichbar ist." + } + return "Änderungen gehen direkt an die Box. Der angezeigte Stand kommt " + + "aus ihrer Antwort, nicht aus der Eingabe." + } + private func targetStepper(zone: AlpicoolState.Zone, title: String, value: Int?) -> some View { let range = state.targetRange return Stepper(value: Binding( diff --git a/CamperMonitor/Views/SensorSetupView.swift b/CamperMonitor/Views/SensorSetupView.swift index f802b1e..a9d1713 100644 --- a/CamperMonitor/Views/SensorSetupView.swift +++ b/CamperMonitor/Views/SensorSetupView.swift @@ -15,13 +15,14 @@ struct SensorSetupView: View { 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 vor der jeweiligen Kippbewegung. + /// Ruhelage, auf die beide Kippbewegungen bezogen werden. @State private var reference = OrientationDetection.Reading(pitch: 0, roll: 0) @State private var noseChange: OrientationDetection.Reading? @@ -83,15 +84,29 @@ struct SensorSetupView: View { .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: "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.") + 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 = .tiltNose } + Button("Zurück") { step = .settle } .buttonStyle(.bordered) Button("Fertig") { finishSideStep() } .buttonStyle(.borderedProminent) @@ -161,13 +176,14 @@ struct SensorSetupView: View { private func finishNoseStep() { guard let live else { return } noseChange = live - reference - reference = live - step = .tiltSide + // 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 } - // 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) diff --git a/Tests/main.swift b/Tests/main.swift index 7170f92..8498e1c 100644 --- a/Tests/main.swift +++ b/Tests/main.swift @@ -794,12 +794,27 @@ if case .failure(let failure) = detect(nose: Reading(pitch: 2, roll: 0), 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) +// Von Hand gekippt geht die andere Achse ein Stück mit. Einzeln betrachtet +// wäre das nicht zuzuordnen, im Paar schon. +if case .success(let sloppy) = detect(nose: Reading(pitch: 14, roll: 6), + side: Reading(pitch: -7, roll: 16)) { + checkEqual("mitlaufende Achse stört die Zuordnung nicht", + sloppy.longitudinalSource, .pitch) + checkEqual("mitlaufend: längs nicht umgekehrt", sloppy.invertLongitudinal, false) + checkEqual("mitlaufend: quer nicht umgekehrt", sloppy.invertLateral, false) } else { - check("schräges Kippen wird abgefangen", false) + check("mitlaufende Achse wird verkraftet", false) +} + +// Dasselbe bei quer eingebautem Sensor. +if case .success(let sloppySideways) = detect(nose: Reading(pitch: 6, roll: 14), + side: Reading(pitch: -16, roll: 7)) { + checkEqual("mitlaufend und quer: Achsen getauscht", + sloppySideways.longitudinalSource, .roll) + checkEqual("mitlaufend und quer: quer umgekehrt", + sloppySideways.invertLateral, true) +} else { + check("mitlaufende Achse bei quer eingebautem Sensor", false) } // Zweimal um dieselbe Achse gekippt ergibt keine Zuordnung.