Kühlbox: Schreibweg absichern und selbst nachbessern
Vier Anläufe ohne Erfolg heisst: die Annahme, der Schreibvorgang komme an, trägt nicht mehr. Also wird sie geprüft, statt weiter darauf zu bauen. Drei Dinge am Schreibweg waren wirklich falsch. Die Reihenfolge der Kandidaten war nicht festgelegt. Bietet ein Merkmal beide Schreibarten an, sind beide Einträge gleichwertig - und `sorted` sichert für gleichwertige Einträge keine Reihenfolge zu. Ob mit oder ohne Bestätigung geschrieben wurde, entschied damit der Zufall, von Start zu Start womöglich verschieden. Jetzt bleibt die Fundreihenfolge erhalten. Ein Schreibvorgang ohne Bestätigung wird von iOS stillschweigend verworfen, wenn der Sendepuffer voll ist. Das wurde nirgends beachtet. Jetzt wartet, was nicht sofort rausgeht, auf die Bereitschaftsmeldung. Schreibfehler waren unsichtbar: für Schreibvorgänge mit Bestätigung gab es gar keine Rückmeldung. Jetzt werden sie gezählt und Fehler gemeldet, beides steht in der Diagnose. Dazu bessert die Sitzung selbst nach. Nach einem Stellbefehl wird geprüft, ob sich die Einstellungen der Box überhaupt geändert haben - an den Einstellungsbytes, nicht an Temperatur und Spannung, die ohnehin schwanken. Bleibt alles gleich, wird einmal mit der anderen Schreibart nachgesetzt. Nimmt die Box nur eine der beiden an, findet die App das jetzt allein. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user