9 Commits
Author SHA1 Message Date
BiasFandClaude Opus 5 c4e444dd27 Kühlbox: fehlenden zweiten Fühler erkennen
Deine Diagnose hat es gezeigt. Die Box sendet 30 Byte Nutzlast, also den
langen Datensatz - hat aber nur einen Fühler. Für den fehlenden zweiten
meldet sie -128, den üblichen Platzhalter, und die Einstellungen der
rechten Zone stehen auf lauter Nullen.

Meine Erkennung hat diesen Platzhalter für einen Messwert gehalten und die
Box deshalb für zweizonig gehalten. Damit ging der lange Stellbefehl raus,
31 Byte statt 20 - und den verwirft sie wortlos. Die Solltemperatur ging
weiter, weil sie ein eigenes, kurzes Kommando hat und von der Zonenfrage
nicht berührt wird. Genau dieses Bild stand im Bericht.

Als zweite Zone gilt jetzt nur noch, wo der zweite Fühler auch wirklich
misst. Für diese Box heisst das: 20 Byte, ein Schreibvorgang.

Nebenbei fiel auf, dass die Anzeige eine rechte Zone mit -128 Grad geführt
hat. Auch das ist damit weg.

Die echte Antwort der Box steht als Prüfung im Testlauf, mitsamt der
Möglichkeit, die Erkennung in den Geräteeinstellungen zu übersteuern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 09:17:28 +02:00
BiasFandClaude Opus 5 8f8edb137b Kühlbox: weniger Piepen, Diagnose zum Weitergeben
Dein Befund grenzt es ein: die Solltemperatur lässt sich stellen, also
kommen Schreibvorgänge an. Es scheitert nur am Einstellungsblock.

Das mehrfache Piepen war meine Schuld. Beantwortet die Box die Anmeldung
nicht, habe ich sie vor jedem Stellbefehl erneut angemeldet - und die Box
quittiert jede Anmeldung mit einem Ton. Jetzt geschieht das höchstens
einmal je Verbindung.

Weiter komme ich nicht ohne die Antwort deiner Box. Ihre Länge entscheidet
darüber, wie lang der Stellbefehl sein muss, und sie steht jetzt als
"Statusdaten der Box" in der Diagnose. Dazu ein Knopf, der alles zusammen
in die Zwischenablage legt - Merkmalsbaum, Weg, Schreibart, letzten Befehl
und letzte Antwort. Das abzutippen wäre zuviel verlangt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 09:08:43 +02:00
BiasFandClaude Opus 5 8b1e2ac39d 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>
2026-08-31 09:03:22 +02:00
BiasFandClaude Opus 5 58de005fa7 Kühlbox: Einstellungsblock in 20-Byte-Stücken schreiben
Die Ursache, belegt statt vermutet. Im Referenzprojekt gibt es einen
Bericht von genau dieser Box - einer Maentum/Plug-in Festival IceCube Dual
(Gruni22/alpicool_ha_ble#20). Dort galt: Solltemperatur ändern geht,
Ein- und Ausschalten nicht. Genau unser Bild.

Der Grund ist nicht das Paket, sondern der Schreibvorgang. Die Box nimmt
nur die 20 Nutzbytes der Standard-MTU je Schreibvorgang an. Der kurze
Temperaturbefehl hat sieben Byte und geht durch, der Einstellungsblock hat
31 und wird abgelehnt. Wir haben bisher nach der ausgehandelten MTU
aufgeteilt - und die ist unter iOS weit grösser als 20, also wurde nie
aufgeteilt. Jetzt gilt für die Kühlbox die Grenze der Box, nicht die der
Verbindung: 31 Byte gehen als 20 und 11 raus, mit der üblichen Pause
dazwischen.

Das mitgeschnittene Paket aus dem Bericht steht als Prüfung im Testlauf.
Unsere Erzeugung trifft es byteweise - Längenbyte, Datenblock, rechte
Zone, Prüfsumme -, es scheiterte also wirklich nur am Schreiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 23:58:47 +02:00
BiasFandClaude Opus 5 568662fd5e Kühlbox: Stellbefehl in der Länge, die die Box erwartet
Diesmal gegen die Referenzimplementierung geprüft statt geraten. Rahmen,
Längenbyte, Prüfsumme, Anmelde- und Abfragepaket stimmen dort byteweise
mit unseren überein. Ein Unterschied blieb: die Länge des Einstellungs-
blocks.

Ich hatte sie an die Zonenerkennung gehängt. Sendet eine Box den langen
Datensatz, füllt den zweiten Block aber mit Nullen, galt sie als einzonig
und bekam den kurzen Befehl - während sie den langen erwartet. Die Box
verwirft ihn dann wortlos, und Ein- und Ausschalten blieb wirkungslos.
Die Referenz hängt die Länge an die Antwort der Box, nicht an eine
Deutung davon. Jetzt auch hier. Für die Anzeige bleibt die Erkennung
unverändert: ein leerer zweiter Block wird weiterhin nicht als zweite
Zone gezeigt.

Zweitens die Anmeldung. Abfragen beantwortet die Box teils auch
unangemeldet, Stellbefehle nicht. Ob die Anmeldung je bestätigt wurde,
wurde bisher nicht verfolgt. Jetzt schon: fehlt die Bestätigung, wird vor
dem Stellbefehl noch einmal angemeldet. In der Diagnose steht der Stand
als eigene Zeile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 23:51:38 +02:00
BiasFandClaude Opus 5 270c701ad6 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>
2026-08-30 23:43:57 +02:00
BiasFandClaude Opus 5 583aa75595 Verbindungssturm abstellen, Einbaulage des Sensors einrichtbar
Zwei Dinge.

Die Kühlbox piepte dauernd und die Bedienung wurde zäh. Ursache war ein
Fehler in der Verbindungslogik: nach einem fehlgeschlagenen Versuch wurde
der Eintrag freigegeben, und das nächste Advertisement löste sofort den
nächsten aus - bei laufendem Scan bis zu einmal je Sekunde. Geräte
quittieren jeden Versuch, Kühlboxen mit einem Piepton. Nebenher wechselte
der Verbindungszustand im selben Takt, was die Oberfläche in eine
Dauerneuzeichnung trieb.

Ein Versuch wird jetzt für eine Weile gesperrt, mit wachsendem Abstand von
fünf bis sechzig Sekunden. Auch nach einem Trennen durch die Gegenseite
wird nicht sofort neu angeklopft - trennt ein Gerät von sich aus, etwa
weil eine andere App verbunden ist, entstünde sonst ein Wechselspiel aus
Verbinden und Trennen. Beim Umkonfigurieren werden die Sperren
zurückgesetzt, damit ein neu eingerichtetes Gerät sofort drankommt.

Dazu entlastet: bei jeder Antwort wurden alle fünf Protokollparser
durchprobiert, auch wenn längst feststand, welches Protokoll gilt. Steht
der Dialekt, läuft nur noch dieser.

Zweitens der Neigungsmesser: je nach Einbaulage meldet er längs und quer
vertauscht oder mit falschem Vorzeichen. Statt die Lage aus einer Liste
raten zu lassen, misst der neue Assistent sie - zweimal kippen, einmal um
jede Achse, und aus der Reaktion ergibt sich die Zuordnung. Schräges oder
zu schwaches Kippen wird erkannt und gemeldet, statt eine zufällige
Zuordnung zu liefern. Die Sitzung führt die Rohwerte des Sensors weiter
mit, weil der Assistent sie unumgerechnet braucht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 23:33:47 +02:00
BiasFandClaude Opus 5 d67b10d4e0 Diagnose ausblenden, bis sie gebraucht wird
Die Geräteansichten zeigten dauerhaft die technischen Angaben: erkanntes
Protokoll, Bluetooth-Merkmale samt vollständigem Merkmalsbaum, gesendete
Befehle und die Rohdaten der letzten Antwort. Das war zum Einrichten der
Geräte nötig - für den Alltag ist es nur Ballast zwischen den Messwerten.

Sie sind jetzt ausgeblendet und über Einstellungen → Diagnose anzeigen
wieder einzublenden. Die Einstellungen sind neu und sitzen im
Fahrzeugmenü des Dashboards.

Eine Ausnahme: Meldet ein Gerät einen Fehler oder fehlt ein
Victron-Schlüssel, erscheinen die Angaben unabhängig von der Einstellung.
Genau dann sind sie das Einzige, was weiterhilft - und niemand denkt in
dem Moment daran, sie erst irgendwo einzuschalten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 23:24:32 +02:00
BiasFandClaude Opus 5 49138312ea Neigung am Fahrzeug darstellen
Neben der Libelle gibt es jetzt eine zweite Darstellung: die Neigung am
Fahrzeug selbst, mit den Zeichnungen aus dem Ursprungsprojekt. Umschaltbar
und über Starts hinweg gemerkt.

Seitenansicht für längs, Heckansicht für quer. Die Heckansicht ist bewusst
gewählt, nicht die Frontansicht: sie teilt die Blickrichtung des Fahrers,
links im Bild ist also links am Fahrzeug. Von vorne betrachtet wäre es
seitenverkehrt und damit beim Ausrichten irreführend.

Die Zeichnungen sind weiss gefüllt mit dunklen Linien. Als Schablone
eingefärbt wurde daraus eine massive Fläche, weil alles Deckende zur
Tintfarbe wird - Fenster, Räder und Konturen verschwanden. Tools/
make-vehicle-art übersetzt deshalb die Helligkeit in Deckkraft: dunkle
Linien voll deckend, helle Karosserie nur angedeutet. Eingefärbt bleibt
die Zeichnung erhalten und passt sich Hell- wie Dunkeldarstellung an.

Die Neigung wird dreifach überhöht gezeigt, sonst wären zwei Grad am
Fahrzeug kaum auszumachen. Das steht als Hinweis darunter, zusammen mit
der Klarstellung, dass die Gradzahlen echt sind - sonst nähme man den
Bildwinkel für den tatsächlichen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 23:22:07 +02:00
25 changed files with 1444 additions and 104 deletions
@@ -0,0 +1,10 @@
{
"images" : [
{
"filename" : "VehicleRear.png",
"idiom" : "universal"
}
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "template-rendering-intent" : "template" }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

@@ -0,0 +1,10 @@
{
"images" : [
{
"filename" : "VehicleSide.png",
"idiom" : "universal"
}
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "template-rendering-intent" : "template" }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

+42 -7
View File
@@ -92,10 +92,24 @@ enum AlpicoolProtocol {
static func signed(_ byte: UInt8) -> Int { Int(Int8(bitPattern: byte)) }
/// Womit diese Boxen einen nicht vorhandenen Fühler melden.
static let missingSensorReading = -128
/// Pause zwischen den Teilstücken eines aufgeteilten Pakets, damit das
/// Gerät sie wieder zusammensetzen kann.
static let chunkDelay: TimeInterval = 0.15
/// Wieviel die Box je Schreibvorgang annimmt.
///
/// Das sind die 20 Nutzbytes der Standard-MTU unabhängig davon, was auf
/// der Verbindung ausgehandelt wurde. Ein längerer Schreibvorgang wird von
/// diesen Boxen abgelehnt; belegt an einer Maentum/Plug-in Festival
/// IceCube Dual, bei der genau deshalb das Ein- und Ausschalten scheiterte,
/// während der kurze Temperaturbefehl durchging
/// (Gruni22/alpicool_ha_ble#20). Das Ändern der Solltemperatur geht mit
/// sieben Byte durch, der Einstellungsblock mit 31 nicht.
static let maxWriteSize = 20
/// Zerlegt ein Paket in schreibbare Stücke.
///
/// Ohne ausgehandelte MTU nimmt BLE nur 20 Nutzbytes je Schreibvorgang an.
@@ -143,16 +157,24 @@ struct AlpicoolState: Equatable {
/// Übersteuerung aus den Geräteeinstellungen.
var zoneMode: FridgeZoneMode = .automatic
/// Rohe Bytes der rechten Zone, für die Erkennung und die Diagnose.
/// Die Einstellungsbytes der rechten Zone, für die Erkennung und die
/// Diagnose. Ohne den Messwert der wird getrennt beurteilt.
var rightZoneBytes: [UInt8] = []
/// Ob die Box wirklich eine zweite Zone hat.
///
/// Die Nutzlastlänge allein taugt nicht: Einzonen-Boxen senden den langen
/// Datensatz teils mit, füllen den zweiten Block aber mit Nullen. Als
/// zweizonig gilt deshalb nur, wo dieser Block überhaupt etwas enthält.
/// Datensatz teils mit und füllen den zweiten Block auf. Zwei Anzeichen
/// verraten das. Erstens meldet die Box für den fehlenden zweiten Fühler
/// -128, den üblichen Platzhalter. Zweitens stehen die Einstellungen der
/// rechten Zone dann auf lauter Nullen oder lauter 0xFF.
///
/// Das ist keine Frage der Anzeige allein: der Stellbefehl fällt für eine
/// Box mit zwei Zonen länger aus, und die falsche Länge wird verworfen.
var detectedDualZone: Bool {
guard rightCurrent != nil, !rightZoneBytes.isEmpty else { return false }
guard let current = rightCurrent,
current != AlpicoolProtocol.missingSensorReading else { return false }
guard !rightZoneBytes.isEmpty else { return false }
return rightZoneBytes.contains { $0 != 0x00 } && rightZoneBytes.contains { $0 != 0xFF }
}
@@ -202,7 +224,7 @@ struct AlpicoolState: Equatable {
rightTarget = AlpicoolProtocol.signed(p[18])
rightCurrent = AlpicoolProtocol.signed(p[26])
runningStatus = Int(p[27])
rightZoneBytes = Array(p[18...26])
rightZoneBytes = Array(p[18...25])
} else {
rightTarget = nil
rightCurrent = nil
@@ -210,6 +232,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 {
@@ -242,8 +275,10 @@ struct AlpicoolState: Equatable {
]
// Der zweite Block gehört nur an den Befehl, wenn die Box wirklich
// zwei Zonen hat. Einzonen-Boxen senden den langen Datensatz teils
// mit, nehmen den langen Befehl aber nicht an.
// zwei Zonen hat. Eine Einzonen-Box sendet den langen Datensatz teils
// trotzdem nimmt aber nur den kurzen Befehl an. Stimmt die Erkennung
// im Einzelfall nicht, lässt sie sich in den Geräteeinstellungen von
// Hand festlegen.
if isDualZone, p.count >= 28 {
data += [
p[18], // Sollwert rechts
+229 -45
View File
@@ -91,11 +91,24 @@ final class BMSSession: NSObject {
}
/// Ob die Kühlbox in dieser Sitzung schon angemeldet wurde.
private var didBind = false
/// Ob die Box die Anmeldung auch beantwortet hat. Abfragen nimmt sie
/// teils auch unangemeldet an, Stellbefehle nicht deshalb wird vor
/// einem Befehl notfalls noch einmal angemeldet.
private(set) var bindAcknowledged = false
/// Ob vor einem Stellbefehl schon einmal nachgemeldet wurde. Jedes Mal
/// anzumelden lässt die Box bei jedem Tastendruck erneut piepen.
private var didRebindForControl = false
private var buffer: [UInt8] = []
private var pollTimer: DispatchSourceTimer?
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] = []
@@ -108,6 +121,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
@@ -208,10 +232,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 {
@@ -228,6 +259,8 @@ final class BMSSession: NSObject {
isNotifyActive = false
didUnlock = false
didBind = false
bindAcknowledged = false
didRebindForControl = false
buffer.removeAll()
activationToken += 1
let token = activationToken
@@ -291,6 +324,7 @@ final class BMSSession: NSObject {
private func beginPolling() {
pollTimer?.cancel()
flushWaitingControls()
poll()
let interval = dialect == .unknown ? searchInterval : pollInterval
let timer = DispatchSource.makeTimerSource(queue: queue)
@@ -387,23 +421,14 @@ final class BMSSession: NSObject {
sentFrameCount += 1
lastSendAt = Date()
// Was in einen Schreibvorgang passt, hängt an der ausgehandelten MTU.
// Längeres muss aufgeteilt werden, sonst verwirft das Gerät es
// wortlos der Einstellungsblock einer Kühlbox ist so ein Fall.
let limit = peripheral.maximumWriteValueLength(for: endpoint.writeType)
let pieces = AlpicoolProtocol.chunks(data, limit: limit)
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)
}
}
@@ -412,6 +437,40 @@ 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
/// aber nur die 20 Byte der Standard-MTU an, auch wenn iOS eine grössere
/// aushandelt und damit weit mehr erlauben würde. Ohne diese Grenze ginge
/// der Einstellungsblock als ein Schreibvorgang raus und die Box würde
/// ihn ablehnen, während die kurzen Befehle durchgehen.
private func writeLimit(for endpoint: Endpoint) -> Int {
let negotiated = peripheral.maximumWriteValueLength(for: endpoint.writeType)
guard dialect == .alpicool else { return negotiated }
return min(negotiated, AlpicoolProtocol.maxWriteSize)
}
// MARK: - Steuern
/// Schickt einen Stellbefehl und fragt kurz darauf den Zustand ab, damit
@@ -419,75 +478,177 @@ 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
}
// Hat die Box die Anmeldung nie beantwortet, wird sie einmal je
// Verbindung nachgeholt. Ein Stellbefehl an eine unangemeldete Box
// wird sonst womöglich verworfen - jedes Mal anzumelden lässt die Box
// aber bei jedem Tastendruck zusätzlich piepen.
guard bindAcknowledged || didRebindForControl else {
didRebindForControl = true
send(AlpicoolProtocol.packet(.bind))
queue.asyncAfter(deadline: .now() + 0.4) { [weak self] in
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.
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
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
// 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
}
if consumeAlpicool() { return }
if consumeWattCycle() { return }
if consumeJBD() { return }
if consumeDalyModbus() { return }
if consumeDalyClassic() { return }
// 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 fridgeFrames { alpicoolState.apply(frame) }
if frames.contains(where: { $0.command == AlpicoolProtocol.Command.bind.rawValue }) {
bindAcknowledged = true
}
for frame in frames { alpicoolState.apply(frame) }
alpicoolState.zoneMode = fridgeZoneMode
onFridgeState?(alpicoolState)
publish(alpicoolState.snapshot(deviceID: deviceID, rssi: nil),
usable: alpicoolState.hasStatus)
return
return true
}
let (wattFrames, wattRemainder) = WattCycleProtocol.extractFrames(from: buffer)
if !wattFrames.isEmpty {
buffer = wattRemainder
@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 wattFrames { wattCycleState.apply(frame) }
for frame in frames { wattCycleState.apply(frame) }
publish(wattCycleState.snapshot(deviceID: deviceID, rssi: nil),
usable: wattCycleState.hasUsableData)
return
return true
}
// 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
/// 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 jbdFrames { jbdState.apply(frame) }
for frame in frames { jbdState.apply(frame) }
publish(jbdState.snapshot(deviceID: deviceID, rssi: nil), usable: jbdState.hasUsableData)
return
return true
}
if let start = buffer.firstIndex(where: { $0 == 0xD2 }),
let registers = DalyProtocol.parseModbusResponse(Array(buffer[start...])) {
@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
return true
}
let (dalyFrames, dalyRemainder) = DalyProtocol.extractA5Frames(from: buffer)
if !dalyFrames.isEmpty {
buffer = dalyRemainder
@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 dalyFrames { dalyState.apply(frame) }
for frame in frames { 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.
publishDiagnostics()
return true
}
/// Erster verwertbarer Rahmen: Kandidat und Dialekt stehen fest, ab jetzt
@@ -514,6 +675,11 @@ final class BMSSession: NSObject {
serviceUUID: currentEndpoint?.write.service?.uuid.uuidString,
isConnected: peripheral.state == .connected,
isNotifyActive: isNotifyActive,
isBound: dialect == .alpicool ? bindAcknowledged : nil,
confirmedWrites: confirmedWrites,
lastWriteError: lastWriteError,
fridgePayloadHex: alpicoolState.lastPayload.isEmpty ? nil
: alpicoolState.lastPayload.map { String(format: "%02X", $0) }.joined(separator: " "),
gattSummary: gattSummary,
sentFrames: sentFrameCount,
receivedBytes: receivedByteCount,
@@ -596,4 +762,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()
}
}
+135 -11
View File
@@ -79,6 +79,17 @@ struct BMSDiagnostics: Hashable {
var isConnected: Bool
/// Ob das Gerät das Abonnieren der Notify-Charakteristik bestätigt hat.
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?
/// Nur bei Kühlboxen: die vollständige Nutzlast der letzten Statusantwort.
/// Daran hängt die Länge des Stellbefehls, deshalb gehört sie in die
/// Diagnose.
var fridgePayloadHex: String?
/// Vollständiger Dienst-/Merkmalsbaum des Geräts.
var gattSummary: [String]
var sentFrames: Int
@@ -162,6 +173,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 +182,35 @@ 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
/// 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
@@ -245,6 +286,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))
@@ -280,10 +322,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) {
@@ -300,12 +339,53 @@ 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)
}
}
}
/// 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
}
}
@@ -346,12 +426,16 @@ 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()
connectedSince.removeAll()
startScanning()
}
@@ -391,6 +475,8 @@ final class BluetoothManager: NSObject {
bmsSessions.removeAll()
levelSessions.removeAll()
connectedPeripherals.removeAll()
connectedSince.removeAll()
pendingControls.removeAll()
}
private func restartScan() {
@@ -416,6 +502,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 +517,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 +729,7 @@ extension BluetoothManager: CBCentralManagerDelegate {
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
guard let device = managed[peripheral.identifier] else { return }
connectedPeripherals[peripheral.identifier] = peripheral
connectedSince[peripheral.identifier] = Date()
if device.role == .leveling {
let session = LevelSession(
@@ -637,6 +744,7 @@ extension BluetoothManager: CBCentralManagerDelegate {
self?.publish { self?.levelStates[device.id] = state }
}
)
session.orientation = device.sensorOrientation
levelSessions[peripheral.identifier] = session
session.start()
return
@@ -655,11 +763,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,
@@ -670,6 +780,7 @@ extension BluetoothManager: CBCentralManagerDelegate {
publish { self.linkStates[device.id] = .failed(message) }
}
connectedPeripherals[peripheral.identifier] = nil
backOff(peripheral.identifier)
}
func centralManager(_ central: CBCentralManager,
@@ -681,11 +792,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 }
// Direkt wieder anfragen; iOS stellt die Verbindung her, sobald das
// Gerät wieder in Reichweite ist.
connectIfNeeded(device)
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)
}
}
}
}
+15 -2
View File
@@ -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
@@ -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?
@@ -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
}
}
@@ -0,0 +1,135 @@
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
/// Soviel deutlicher muss die gewinnende Deutung sein als die andere.
static let ambiguityMargin = 1.3
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 "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<SensorOrientation, Failure> {
guard max(abs(nose.pitch), abs(nose.roll)) >= minimumTilt,
max(abs(side.pitch), abs(side.roll)) >= minimumTilt else {
return .failure(.tooLittleMovement)
}
// 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 = 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 < 0
// Linke Seite nach unten heisst: rechts steht höher, die Querneigung
// ist positiv.
orientation.invertLateral = lateral < 0
return .success(orientation)
}
}
@@ -14,6 +14,7 @@ struct AlignmentAssistantView: View {
@State private var assistant = AlignmentAssistant()
@State private var didAnnounceTarget = false
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
@@ -21,9 +22,21 @@ struct AlignmentAssistantView: View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
Picker("Darstellung", selection: $displayStyle) {
ForEach(LevelDisplayStyle.allCases) { style in
Text(style.title).tag(style)
}
}
.pickerStyle(.segmented)
.padding(.top, 8)
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxWidth: 320)
.padding(.top, 8)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll)
}
adviceBanner
+8 -1
View File
@@ -6,6 +6,7 @@ struct DashboardView: View {
@State private var isAddingDevice = false
@State private var isManagingProfiles = false
@State private var isShowingSettings = false
private let columns = [GridItem(.adaptive(minimum: 300), spacing: 16)]
@@ -55,6 +56,9 @@ struct DashboardView: View {
.sheet(isPresented: $isManagingProfiles) {
ProfilesView()
}
.sheet(isPresented: $isShowingSettings) {
SettingsView()
}
}
}
@@ -74,9 +78,12 @@ struct DashboardView: View {
}
}
Divider()
Button("Fahrzeuge verwalten…", systemImage: "gearshape") {
Button("Fahrzeuge verwalten…", systemImage: "car.2") {
isManagingProfiles = true
}
Button("Einstellungen…", systemImage: "gearshape") {
isShowingSettings = true
}
} label: {
Label(store.activeProfile?.name ?? "Fahrzeug",
systemImage: store.activeProfile?.symbol ?? "box.truck")
+78 -2
View File
@@ -11,6 +11,21 @@ struct DeviceDetailView: View {
@State private var editedName = ""
@State private var keyInput = ""
@State private var showDeleteConfirmation = false
@State private var didCopyReport = false
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
/// Ob die technischen Angaben eingeblendet werden.
///
/// Im Alltag stören sie nur. Meldet ein Gerät aber einen Fehler oder fehlt
/// der Schlüssel, sind sie genau das, was weiterhilft dann werden sie
/// unabhängig von der Einstellung gezeigt.
private var showsTechnicalDetails: Bool {
if showDiagnostics { return true }
switch linkState {
case .failed, .needsKey: return true
default: return false
}
}
/// Immer der aktuelle Stand aus dem Speicher `device` ist die
/// Momentaufnahme beim Öffnen und veraltet nach jeder Änderung.
@@ -88,8 +103,8 @@ struct DeviceDetailView: View {
if currentDevice.role.transport == .advertisement {
keySection
diagnosticsSection
} else {
if showsTechnicalDetails { diagnosticsSection }
} else if showsTechnicalDetails {
bmsDiagnosticsSection
}
@@ -258,6 +273,33 @@ struct DeviceDetailView: View {
return String(format: "0x%02X", byte)
}
/// Alles auf einmal, zum Weitergeben. Einzeln abzutippen ist zuviel
/// verlangt, und gerade der Merkmalsbaum ist zu lang dafür.
private func report(_ info: BMSDiagnostics) -> String {
var lines = [
"Gerät: \(currentDevice.name) (\(currentDevice.role.title))",
"Protokoll: \(info.dialect)",
"Verbunden: \(info.isConnected ? "ja" : "nein")",
"Empfang abonniert: \(info.isNotifyActive ? "ja" : "nein")",
]
if let position = info.endpointPosition {
lines.append("Weg: \(position.index) von \(position.total)")
}
if let endpoint = info.endpointLabel { lines.append("Merkmal: \(endpoint)") }
if let isBound = info.isBound { lines.append("Angemeldet: \(isBound ? "ja" : "nein")") }
lines.append("Gesendet: \(info.sentFrames) · empfangen: \(info.receivedBytes) Byte"
+ " · bestätigt: \(info.confirmedWrites)")
if let writeError = info.lastWriteError { lines.append("Schreibfehler: \(writeError)") }
if let command = info.lastCommandHex { lines.append("Letzter Stellbefehl: \(command)") }
if let hex = info.lastResponseHex { lines.append("Letzte Antwort: \(hex)") }
if let payload = info.fridgePayloadHex { lines.append("Statusdaten: \(payload)") }
if !info.gattSummary.isEmpty {
lines.append("Merkmale:")
lines.append(contentsOf: info.gattSummary)
}
return lines.joined(separator: "\n")
}
/// Welches Protokoll das BMS spricht und was zuletzt ankam.
@ViewBuilder
private var bmsDiagnosticsSection: some View {
@@ -286,6 +328,24 @@ struct DeviceDetailView: View {
systemImage: info.isNotifyActive ? "checkmark.circle" : "xmark.circle")
.foregroundStyle(info.isNotifyActive ? .green : .orange)
}
if let isBound = info.isBound {
LabeledContent("Angemeldet") {
Label(isBound ? "ja" : "nein",
systemImage: isBound ? "checkmark.circle" : "xmark.circle")
.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 {
@@ -318,6 +378,22 @@ struct DeviceDetailView: View {
.textSelection(.enabled)
}
}
if let payload = info.fridgePayloadHex {
VStack(alignment: .leading, spacing: 4) {
Text("Statusdaten der Box (\(payload.split(separator: " ").count) Byte)")
Text(payload)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
Button {
UIPasteboard.general.string = report(info)
didCopyReport = true
} label: {
Label(didCopyReport ? "Diagnose kopiert" : "Diagnose kopieren",
systemImage: didCopyReport ? "checkmark" : "doc.on.doc")
}
} header: {
Text("Diagnose")
} footer: {
+15 -2
View File
@@ -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(
+35
View File
@@ -109,12 +109,26 @@ struct LevelControls: View {
@Environment(DeviceStore.self) private var store
@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 {
VStack(spacing: 16) {
Picker("Darstellung", selection: $displayStyle) {
ForEach(LevelDisplayStyle.allCases) { style in
Text(style.title).tag(style)
}
}
.pickerStyle(.segmented)
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxHeight: 220)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll)
}
if let instruction = state.instruction {
Label(instruction,
@@ -151,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)
@@ -177,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)
}
+201
View File
@@ -0,0 +1,201 @@
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 settle
case tiltSide
case done(SensorOrientation)
case failed(OrientationDetection.Failure)
}
@State private var step: Step = .intro
/// Ruhelage, auf die beide Kippbewegungen bezogen werden.
@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 gehts") {
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 .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: "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 = .settle }
.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
// 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 }
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()
}
}
+38
View File
@@ -0,0 +1,38 @@
import SwiftUI
/// App-weite Einstellungen. Bewusst knapp gehalten was ein einzelnes Gerät
/// betrifft, steht bei diesem Gerät.
struct SettingsView: View {
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section {
Toggle("Diagnose anzeigen", isOn: $showDiagnostics)
} header: {
Text("Fehlersuche")
} footer: {
Text("Zeigt bei jedem Gerät die technischen Angaben: erkanntes "
+ "Protokoll, Bluetooth-Merkmale, gesendete Befehle und die "
+ "Rohdaten der letzten Antwort. Für den Alltag nicht nötig "
+ "hilfreich, wenn ein Gerät sich nicht wie erwartet verhält.\n\n"
+ "Meldet ein Gerät einen Fehler, werden die Angaben ohnehin "
+ "eingeblendet, auch wenn das hier ausgeschaltet ist.")
}
}
.navigationTitle("Einstellungen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Fertig") { dismiss() }
}
}
}
}
}
enum AppSettings {
static let showDiagnosticsKey = "showDiagnostics"
}
+116
View File
@@ -0,0 +1,116 @@
import SwiftUI
/// Zeigt die Neigung am Fahrzeug selbst, statt an einer abstrakten Blase.
///
/// Zwei Ansichten, jede um ihre Achse gekippt:
///
/// * **Seitenansicht** für die Längsneigung. Die Front zeigt nach links, das
/// Heck nach rechts.
/// * **Heckansicht** für die Querneigung. Sie teilt die Blickrichtung des
/// Fahrers, links im Bild ist also links am Fahrzeug bei einer
/// Frontansicht wäre es seitenverkehrt.
///
/// In beiden Fällen wird gegen den mathematischen Drehsinn gekippt: Steht das
/// Heck höher, muss die rechte Bildseite nach oben.
struct VehicleTiltView: View {
let pitch: Double?
let roll: Double?
/// Kleine Neigungen sind am Fahrzeug sonst kaum zu erkennen zwei Grad
/// wären ein knappes Grad Bildneigung. Die Überhöhung wird angeschrieben,
/// damit niemand den Winkel für bare Münze nimmt.
static let exaggeration: Double = 3
var body: some View {
VStack(spacing: 20) {
tiltPanel(image: "VehicleSide",
angle: pitch,
title: "Längs",
lowerLabel: "Front",
upperLabel: "Heck",
aspect: 925.0 / 600.0)
tiltPanel(image: "VehicleRear",
angle: roll,
title: "Quer",
lowerLabel: "links",
upperLabel: "rechts",
aspect: 1)
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
Text(String(format: "Neigung %.0f-fach überhöht dargestellt "
+ "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.",
Self.exaggeration))
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
}
private func tiltPanel(image: String,
angle: Double?,
title: String,
lowerLabel: String,
upperLabel: String,
aspect: Double) -> some View {
VStack(spacing: 8) {
HStack {
Text(title)
.font(.subheadline.weight(.medium))
Spacer()
Text(angle.map { String(format: "%.1f°", $0) } ?? "")
.font(.subheadline.weight(.semibold).monospacedDigit())
.foregroundStyle(colour(for: angle))
}
ZStack {
// Waagerechte als Bezug ohne sie ist eine kleine Neigung
// nicht einzuschätzen.
Rectangle()
.fill(Color.secondary.opacity(0.35))
.frame(height: 1)
Image(image)
.resizable()
.scaledToFit()
.foregroundStyle(colour(for: angle))
.aspectRatio(aspect, contentMode: .fit)
.rotationEffect(.degrees(-(angle ?? 0) * Self.exaggeration))
.animation(.spring(duration: 0.4), value: angle)
.opacity(angle == nil ? 0.3 : 1)
}
.frame(height: 110)
HStack {
Text(lowerLabel)
Spacer()
Text(upperLabel)
}
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private func colour(for angle: Double?) -> Color {
guard let angle else { return .secondary }
if abs(angle) <= LevelState.levelTolerance { return .green }
if abs(angle) <= 2 { return .orange }
return .red
}
}
/// Umschalter zwischen den beiden Darstellungen, gemerkt über Starts hinweg.
enum LevelDisplayStyle: String, CaseIterable, Identifiable {
case bubble
case vehicle
var id: String { rawValue }
var title: String {
switch self {
case .bubble: return "Libelle"
case .vehicle: return "Fahrzeug"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+40 -5
View File
@@ -132,12 +132,31 @@ MPU6050, der Längs- und Querneigung über Bluetooth bereitstellt. Er bewirbt
seinen Dienst, wird beim Einrichten also sicher erkannt und die Art
vorbelegt.
Die Detailansicht zeigt eine Libelle: die Blase wandert dorthin, wo das
Fahrzeug höher steht, wie bei einer echten Wasserwaage. Grün heißt eben
(bis 0,5°), orange bis zwei Grad, darüber rot. Dazu steht in Worten, welche
Seite höher steht.
Die Detailansicht bietet zwei Darstellungen, umschaltbar und über Starts
hinweg gemerkt:
**Vor der ersten Nutzung kalibrieren:** Fahrzeug eben stellen, dann *Auf
* **Libelle** die Blase wandert dorthin, wo das Fahrzeug höher steht, wie bei
einer echten Wasserwaage. Ringe markieren Toleranz und zwei Grad.
* **Fahrzeug** Seitenansicht für längs, Heckansicht für quer, beide gekippt.
Die Heckansicht teilt die Blickrichtung des Fahrers, links im Bild ist also
links am Fahrzeug. Die Neigung ist dreifach überhöht dargestellt, sonst wäre
sie bei zwei Grad kaum zu erkennen; die angezeigten Gradzahlen sind echt.
In beiden Fällen: grün heißt eben (bis 0,5°), orange bis zwei Grad, darüber
rot. Dazu steht in Worten, welche Seite höher steht.
Die Fahrzeugzeichnungen stammen aus dem Ursprungsprojekt VanAlign Pro. Sie
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 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.
@@ -172,6 +191,18 @@ damit exakt: 2,0° Querneigung bei 2,00 m Spurweite ergeben 7,0 cm unter die
tieferstehende Seite. Die Maße stehen im Fahrzeugprofil, erreichbar über das
ⓘ in der Fahrzeugliste.
## Diagnose
Im Alltag zeigt die App nur Messwerte und Bedienelemente. Die technischen
Angaben erkanntes Protokoll, Bluetooth-Merkmale, gesendete Befehle, Rohdaten
der letzten Antwort sind ausgeblendet und lassen sich unter *Einstellungen →
Diagnose anzeigen* einschalten. Die Einstellungen sitzen im Fahrzeugmenü oben
links im Dashboard.
Meldet ein Gerät einen Fehler oder fehlt ein Victron-Schlüssel, werden die
Angaben unabhängig davon eingeblendet dann sind sie genau das, was
weiterhilft.
## Mehrere Fahrzeuge
Oben links im Dashboard sitzt der Fahrzeugwechsel. Jedes Profil hat seinen
@@ -208,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
@@ -235,6 +267,9 @@ CamperMonitor/
├── FridgeControls.swift Bedienelemente der Kühlbox
├── LevelView.swift Libelle und Kalibrierung
├── 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
```
+211 -3
View File
@@ -530,9 +530,9 @@ checkEqual("leerer zweiter Block gilt nicht als zweite Zone", padded.isDualZone,
// 20 Byte noch in einen Schreibvorgang, aus dem langen nicht mehr. Ohne
// Aufteilen verwirft die Box ihn wortlos genau das war der Fehler.
checkEqual("Stellbefehl aus dem kurzen Datensatz", offBytes.count, 20)
// Eine als einzonig erkannte Box bekommt auch nur den kurzen Befehl,
// obwohl sie den langen Datensatz sendet.
checkEqual("kurzer Stellbefehl bei einzoniger Box",
// Eine Einzonen-Box, die den langen Datensatz nur auffüllt, bekommt den
// kurzen Befehl den langen nimmt sie nicht an.
checkEqual("aufgefüllter Datensatz heisst trotzdem kurzer Stellbefehl",
padded.settingsCommand(poweredOn: false)?.count, 20)
padded.zoneMode = .dual
@@ -543,6 +543,114 @@ check("aus dem langen Datensatz wird er zu lang für einen Schreibvorgang",
longPacket.count > 20, "-> \(longPacket.count) Byte")
let pieces = AlpicoolProtocol.chunks(longPacket, limit: 20)
checkEqual("wird in zwei Stücke geteilt", pieces.count, 2)
// Ein echtes Paket der Maentum/Plug-in Festival IceCube Dual, mitgeschnitten
// beim Ausschalten (Gruni22/alpicool_ha_ble#20). Es muss byteweise
// herauskommen - und in zwei Schreibvorgänge zerfallen, weil die Box längere
// nicht annimmt. Genau daran scheiterte das Ein- und Ausschalten.
let iceCubePayload: [UInt8] = [
0x00, // Bedienfeld frei
0x01, // eingeschaltet
0x01, // Eco
0x02, // Batteriewächter hoch
0x14, // Soll links 20
0x14, 0xEC, // Grenzen 20 / -20
0x02, // Rückschaltdifferenz
0x00, // Anlaufverzögerung
0x00, // Celsius
0xFD, 0xFD, 0xFD, 0x00, // Kompressordrehzahlen
0x0A, // Ist links 10
0x57, // 87 %
0x0C, 0x06, // 12,6 V
0x14, // Soll rechts 20
0x00, 0x00,
0x02, // Rückschaltdifferenz rechts
0xFD, 0xFD, 0xFD, 0x00, // Kompressordrehzahlen rechts
0x0A, // Ist rechts 10
0x01, // Kompressor läuft
]
var iceCube = AlpicoolState()
for frame in AlpicoolProtocol.extractFrames(from:
alpicoolResponse(command: 0x01, payload: iceCubePayload)).frames {
iceCube.apply(frame)
}
let expectedOff = Data([
0xFE, 0xFE, 0x1C, 0x02,
0x00, 0x00, 0x01, 0x02, 0x14, 0x14, 0xEC, 0x02, 0x00, 0x00,
0xFD, 0xFD, 0xFD, 0x00,
0x14, 0x00, 0x00, 0x02, 0xFD, 0xFD, 0xFD, 0x00, 0x00, 0x00, 0x00,
0x09, 0x37,
])
checkEqual("Ausschaltbefehl der IceCube Dual stimmt byteweise",
iceCube.settingsCommand(poweredOn: false), expectedOff)
// Eine echte Box aus dem Feld: 30 Byte Nutzlast, aber nur ein Fühler. Sie
// füllt den zweiten Block mit Nullen auf und meldet für den fehlenden
// zweiten Fühler -128. Der Stellbefehl muss trotzdem der kurze sein - der
// lange wurde von ihr wortlos verworfen, und genau daran scheiterte das
// Ein- und Ausschalten.
let paddedIceCubePayload: [UInt8] = [
0x00, // Bedienfeld frei
0x01, // eingeschaltet
0x01, // Eco
0x02, // Batteriewächter hoch
0x09, // Soll 9
0x14, 0xEC, // Grenzen 20 / -20
0x02, 0x00, 0x00, // Rückschaltdifferenz, Verzögerung, Celsius
0x00, 0x00, 0xFD, 0x00, // Kompressordrehzahlen
0x17, // Ist 23
0x64, // 100 %
0x0E, 0x03, // 14,3 V
0x00, 0x00, 0x00, 0x00, // rechte Zone: nur Füllung
0x00, 0x00, 0x00, 0x00,
0x80, // kein zweiter Fühler
0x00, // Kompressor aus
0x01, 0x00, // was darüber hinaus kommt
]
var single = AlpicoolState()
for frame in AlpicoolProtocol.extractFrames(from:
alpicoolResponse(command: 0x01, payload: paddedIceCubePayload)).frames {
single.apply(frame)
}
checkEqual("30 Byte Nutzlast werden gelesen", single.leftCurrent, 23)
checkEqual("fehlender zweiter Fühler heisst eine Zone", single.isDualZone, false)
checkEqual("und damit der kurze Stellbefehl",
single.settingsCommand(poweredOn: false)?.count, 20)
checkEqual("der in einen Schreibvorgang passt",
AlpicoolProtocol.chunks(single.settingsCommand(poweredOn: false)!,
limit: AlpicoolProtocol.maxWriteSize).count, 1)
// Von Hand lässt sich die Erkennung übersteuern, falls sie danebenliegt.
single.zoneMode = .dual
checkEqual("von Hand erzwungen wird er wieder lang",
single.settingsCommand(poweredOn: false)?.count, 31)
single.zoneMode = .automatic
// 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])
checkEqual("kein Stück überschreitet die Grenze", pieces.allSatisfy { $0.count <= 20 }, true)
checkEqual("zusammengesetzt ergibt sich das Original",
[UInt8](pieces.reduce(Data(), +)), [UInt8](longPacket))
@@ -741,5 +849,105 @@ 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<SensorOrientation, OrientationDetection.Failure> {
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)
}
// 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("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.
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)
+17
View File
@@ -17,3 +17,20 @@ swiftc -O -o /tmp/make-app-icon Tools/make-app-icon.swift
```
Nur nötig, wenn `logo.png` sich ändert das erzeugte Icon liegt im Repo.
## make-vehicle-art.swift
Bereitet die Fahrzeugzeichnungen aus VanAlign Pro für die Neigungsanzeige auf.
Die Vorlagen sind weiß gefüllt mit dunklen Linien und Fenstern. Als Schablone
eingefärbt ginge beides verloren, weil alles Deckende zur Tintfarbe wird
übrig bliebe eine massive Fläche.
Das Skript übersetzt deshalb die Helligkeit in Deckkraft: dunkle Linien werden
voll deckend, die helle Karosserie nur angedeutet. Eingefärbt ergibt das eine
gefüllte Form, in der die Details erhalten bleiben.
```bash
swiftc -O -o /tmp/make-vehicle-art Tools/make-vehicle-art.swift
sips --resampleHeight 600 heckansicht.png --out /tmp/rear.png
/tmp/make-vehicle-art /tmp/rear.png CamperMonitor/Assets.xcassets/VehicleRear.imageset/VehicleRear.png
```
+61
View File
@@ -0,0 +1,61 @@
import AppKit
import CoreGraphics
import Foundation
// Die Zeichnungen sind weiss gefüllt mit dunklen Linien und Fenstern. Als
// Schablone eingefärbt geht beides verloren, weil alles Deckende zur
// Tintfarbe wird - übrig bleibt eine massive Fläche.
//
// Deshalb wird die Helligkeit in Deckkraft übersetzt: dunkle Linien werden
// voll deckend, die helle Karosserie nur angedeutet. Eingefärbt ergibt das
// eine gefüllte Form, in der die Details erhalten bleiben.
let arguments = CommandLine.arguments
guard arguments.count >= 3,
let source = NSImage(contentsOfFile: arguments[1]),
let image = source.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
print("Quelle nicht lesbar"); exit(1)
}
let width = image.width, height = image.height
let bytesPerRow = width * 4
var pixels = [UInt8](repeating: 0, count: bytesPerRow * height)
guard let context = CGContext(data: &pixels, width: width, height: height,
bitsPerComponent: 8, bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else {
print("Puffer nicht anlegbar"); exit(1)
}
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
/// Wie stark die Fläche noch durchscheint.
let fill = 0.22
var changed = 0
for index in stride(from: 0, to: pixels.count, by: 4) {
let alpha = Double(pixels[index + 3]) / 255
guard alpha > 0.02 else {
pixels[index] = 0; pixels[index + 1] = 0; pixels[index + 2] = 0; pixels[index + 3] = 0
continue
}
// Premultipliziert: erst zurückrechnen, um die echte Helligkeit zu sehen.
let r = Double(pixels[index]) / 255 / alpha
let g = Double(pixels[index + 1]) / 255 / alpha
let b = Double(pixels[index + 2]) / 255 / alpha
let brightness = min(1, max(0, 0.299 * r + 0.587 * g + 0.114 * b))
let newAlpha = alpha * (fill + (1 - fill) * (1 - brightness))
// Die Farbe selbst spielt keine Rolle, sie wird ohnehin eingefärbt.
let value = UInt8(newAlpha * 255)
pixels[index] = value; pixels[index + 1] = value; pixels[index + 2] = value
pixels[index + 3] = value
changed += 1
}
guard let result = context.makeImage() else { print("Bild leer"); exit(1) }
let rep = NSBitmapImageRep(cgImage: result)
guard let png = rep.representation(using: .png, properties: [:]) else {
print("PNG nicht erzeugbar"); exit(1)
}
try png.write(to: URL(fileURLWithPath: arguments[2]))
print("\(arguments[2]): \(changed) Bildpunkte umgesetzt")
+1
View File
@@ -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