forked from fritob/Camper-Monitor
Kühlbox wieder bedienbar, Einbaulage richtig messen
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>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user