diff --git a/CamperMonitor/Bluetooth/AlpicoolProtocol.swift b/CamperMonitor/Bluetooth/AlpicoolProtocol.swift index 8a2d930..6a9c872 100644 --- a/CamperMonitor/Bluetooth/AlpicoolProtocol.swift +++ b/CamperMonitor/Bluetooth/AlpicoolProtocol.swift @@ -221,6 +221,17 @@ struct AlpicoolState: Equatable { } } + /// Die Bytes, die ein Stellbefehl ändert. + /// + /// Messwerte gehören nicht dazu: Temperatur und Spannung schwanken + /// ohnehin, an ihnen liesse sich nicht ablesen, ob ein Befehl gewirkt hat. + var settingsFingerprint: [UInt8] { + guard lastPayload.count >= 18 else { return [] } + var bytes = [lastPayload[0], lastPayload[1], lastPayload[2], lastPayload[4]] + if lastPayload.count >= 28 { bytes.append(lastPayload[18]) } + return bytes + } + // MARK: - Stellbefehle static func setTarget(zone: Zone, to value: Int) -> Data { diff --git a/CamperMonitor/Bluetooth/BMSSession.swift b/CamperMonitor/Bluetooth/BMSSession.swift index ee8343d..1e81340 100644 --- a/CamperMonitor/Bluetooth/BMSSession.swift +++ b/CamperMonitor/Bluetooth/BMSSession.swift @@ -118,6 +118,17 @@ final class BMSSession: NSObject { /// Ob auf diesem Kandidaten schon freigeschaltet wurde. private var didUnlock = false private var lastSendAt: Date? + /// Was noch rausgeschrieben werden muss. + /// + /// Ein Schreibvorgang ohne Bestätigung wird von iOS stillschweigend + /// verworfen, wenn der Sendepuffer gerade voll ist. Deshalb wird nur + /// geschrieben, solange iOS bereit ist, und der Rest wartet auf die + /// Rückmeldung. + private var outbox: [Data] = [] + /// Fehler des letzten bestätigten Schreibvorgangs, für die Diagnose. + private var lastWriteError: String? + /// Wieviele Schreibvorgänge das Gerät bestätigt hat. + private var confirmedWrites = 0 /// Abstand zwischen zwei Abfragerunden im Normalbetrieb. var pollInterval: TimeInterval = 5 @@ -218,10 +229,17 @@ final class BMSSession: NSObject { } } - // Bekannte Paare nach vorn, der Rest in Fundreihenfolge. - endpoints = candidates.sorted { lhs, rhs in - lhs.isKnownPair && !rhs.isKnownPair - } + // Bekannte Paare nach vorn, der Rest in Fundreihenfolge. Die + // Fundreihenfolge muss dabei erhalten bleiben: `sorted` allein + // garantiert das nicht, und dann entschiede der Zufall, ob mit oder + // ohne Bestätigung geschrieben wird. + endpoints = candidates.enumerated() + .sorted { lhs, rhs in + lhs.element.isKnownPair == rhs.element.isKnownPair + ? lhs.offset < rhs.offset + : lhs.element.isKnownPair + } + .map(\.element) endpointIndex = 0 guard !endpoints.isEmpty else { @@ -402,16 +420,11 @@ final class BMSSession: NSObject { let pieces = AlpicoolProtocol.chunks(data, limit: writeLimit(for: endpoint)) for (index, piece) in pieces.enumerated() { - guard index > 0 else { - peripheral.writeValue(piece, for: endpoint.write, type: endpoint.writeType) - continue - } + guard index > 0 else { enqueue(piece); continue } queue.asyncAfter( deadline: .now() + Double(index) * AlpicoolProtocol.chunkDelay ) { [weak self] in - guard let self, self.peripheral.state == .connected, - let current = self.currentEndpoint else { return } - self.peripheral.writeValue(piece, for: current.write, type: current.writeType) + self?.enqueue(piece) } } @@ -420,6 +433,27 @@ final class BMSSession: NSObject { publishDiagnostics() } + private func enqueue(_ piece: Data) { + outbox.append(piece) + drainOutbox() + } + + /// Schreibt, solange iOS Schreibvorgänge annimmt. + private func drainOutbox() { + guard let endpoint = currentEndpoint, peripheral.state == .connected else { + outbox.removeAll() + return + } + while !outbox.isEmpty { + if endpoint.writeType == .withoutResponse, !peripheral.canSendWriteWithoutResponse { + // Der Rest geht raus, sobald iOS sich wieder meldet. + return + } + peripheral.writeValue(outbox.removeFirst(), + for: endpoint.write, type: endpoint.writeType) + } + } + /// Wieviel je Schreibvorgang rausgeht. /// /// Grundsätzlich das, was die Verbindung hergibt. Die Kühlboxen nehmen @@ -457,22 +491,50 @@ final class BMSSession: NSObject { guard bindAcknowledged else { send(AlpicoolProtocol.packet(.bind)) queue.asyncAfter(deadline: .now() + 0.4) { [weak self] in - guard let self else { return } - self.send(packet) - self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in - guard let self, self.dialect == .alpicool else { return } - self.send(AlpicoolProtocol.packet(.query)) - } + self?.deliverControl(packet, attempt: 0) } return } + deliverControl(packet, attempt: 0) + } + + /// Schickt den Befehl und prüft, ob er gewirkt hat. + /// + /// Manche Module nehmen nur eine der beiden Schreibarten an und melden das + /// nicht – der Befehl verschwindet dann lautlos. Bleiben die Einstellungen + /// der Box unverändert, wird deshalb einmal mit der anderen Art nachgesetzt. + private func deliverControl(_ packet: Data, attempt: Int) { + let before = alpicoolState.settingsFingerprint + let token = activationToken send(packet) + // Genug Abstand, damit ein aufgeteiltes Paket vollständig draußen ist. queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in guard let self, self.dialect == .alpicool else { return } self.send(AlpicoolProtocol.packet(.query)) } + + guard attempt == 0 else { return } + queue.asyncAfter(deadline: .now() + 3.5) { [weak self] in + guard let self, self.activationToken == token, + self.dialect == .alpicool, self.peripheral.state == .connected, + self.alpicoolState.settingsFingerprint == before, + let index = self.alternateWriteTypeIndex() else { return } + self.endpointIndex = index + self.publishDiagnostics() + self.deliverControl(packet, attempt: 1) + } + } + + /// Derselbe Kanal, nur mit der anderen Schreibart. + private func alternateWriteTypeIndex() -> Int? { + guard let current = currentEndpoint else { return nil } + return endpoints.firstIndex { + $0.write.uuid == current.write.uuid + && $0.notify.uuid == current.notify.uuid + && $0.writeType != current.writeType + } } /// Schickt raus, was während des Verbindungsaufbaus aufgelaufen ist. @@ -607,6 +669,8 @@ final class BMSSession: NSObject { isConnected: peripheral.state == .connected, isNotifyActive: isNotifyActive, isBound: dialect == .alpicool ? bindAcknowledged : nil, + confirmedWrites: confirmedWrites, + lastWriteError: lastWriteError, gattSummary: gattSummary, sentFrames: sentFrameCount, receivedBytes: receivedByteCount, @@ -689,4 +753,22 @@ extension BMSSession: CBPeripheralDelegate { guard error == nil, let value = characteristic.value, !value.isEmpty else { return } consume(value) } + + /// Nur bei Schreibvorgängen mit Bestätigung. Ohne Bestätigung meldet iOS + /// nichts zurück – auch keinen Fehler. + func peripheral(_ peripheral: CBPeripheral, + didWriteValueFor characteristic: CBCharacteristic, + error: Error?) { + if let error { + lastWriteError = error.localizedDescription + } else { + lastWriteError = nil + confirmedWrites += 1 + } + publishDiagnostics() + } + + func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + drainOutbox() + } } diff --git a/CamperMonitor/Bluetooth/BluetoothManager.swift b/CamperMonitor/Bluetooth/BluetoothManager.swift index 4e175e7..8dc2f50 100644 --- a/CamperMonitor/Bluetooth/BluetoothManager.swift +++ b/CamperMonitor/Bluetooth/BluetoothManager.swift @@ -81,6 +81,11 @@ struct BMSDiagnostics: Hashable { var isNotifyActive: Bool /// Nur bei Kühlboxen: ob die Box die Anmeldung beantwortet hat. var isBound: Bool? + /// Wieviele Schreibvorgänge das Gerät bestätigt hat. Zählt nur mit, wenn + /// mit Bestätigung geschrieben wird. + var confirmedWrites: Int + /// Fehler des letzten bestätigten Schreibvorgangs. + var lastWriteError: String? /// Vollständiger Dienst-/Merkmalsbaum des Geräts. var gattSummary: [String] var sentFrames: Int diff --git a/CamperMonitor/Views/DeviceDetailView.swift b/CamperMonitor/Views/DeviceDetailView.swift index 7bd7bc0..397a4dd 100644 --- a/CamperMonitor/Views/DeviceDetailView.swift +++ b/CamperMonitor/Views/DeviceDetailView.swift @@ -307,6 +307,17 @@ struct DeviceDetailView: View { .foregroundStyle(isBound ? .green : .orange) } } + if info.confirmedWrites > 0 { + LabeledContent("Schreibvorgänge bestätigt", + value: "\(info.confirmedWrites)") + } + if let writeError = info.lastWriteError { + LabeledContent("Letzter Schreibfehler") { + Text(writeError) + .font(.caption) + .foregroundStyle(.red) + } + } LabeledContent("Gesendet / empfangen", value: "\(info.sentFrames) Anfragen / \(info.receivedBytes) Byte") if let lastSendAt = info.lastSendAt { diff --git a/Tests/main.swift b/Tests/main.swift index 6cccacc..32df4b6 100644 --- a/Tests/main.swift +++ b/Tests/main.swift @@ -584,6 +584,30 @@ let expectedOff = Data([ ]) checkEqual("Ausschaltbefehl der IceCube Dual stimmt byteweise", iceCube.settingsCommand(poweredOn: false), expectedOff) +// Ob ein Stellbefehl gewirkt hat, wird an den Einstellungsbytes abgelesen - +// nicht an den Messwerten, die ohnehin schwanken. +let fingerprintBefore = iceCube.settingsFingerprint +var iceCubeCooler = iceCube +var driftingPayload = iceCubePayload +driftingPayload[14] = 0x08 // Ist links gefallen +driftingPayload[16] = 0x0B // Spannung gesunken +for frame in AlpicoolProtocol.extractFrames(from: + alpicoolResponse(command: 0x01, payload: driftingPayload)).frames { + iceCubeCooler.apply(frame) +} +checkEqual("schwankende Messwerte gelten nicht als Wirkung", + iceCubeCooler.settingsFingerprint, fingerprintBefore) + +var iceCubeOff = iceCube +var switchedPayload = iceCubePayload +switchedPayload[1] = 0x00 // ausgeschaltet +for frame in AlpicoolProtocol.extractFrames(from: + alpicoolResponse(command: 0x01, payload: switchedPayload)).frames { + iceCubeOff.apply(frame) +} +check("ein geänderter Schalter gilt als Wirkung", + iceCubeOff.settingsFingerprint != fingerprintBefore) + checkEqual("er passt nicht in einen Schreibvorgang", AlpicoolProtocol.chunks(expectedOff, limit: AlpicoolProtocol.maxWriteSize) .map(\.count), [20, 11])