Compare commits
9
Commits
767fe4e798
...
c4e444dd27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4e444dd27 | ||
|
|
8f8edb137b | ||
|
|
8b1e2ac39d | ||
|
|
58de005fa7 | ||
|
|
568662fd5e | ||
|
|
270c701ad6 | ||
|
|
583aa75595 | ||
|
|
d67b10d4e0 | ||
|
|
49138312ea |
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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,77 +478,179 @@ 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
|
||||
adopt(.alpicool)
|
||||
for frame in fridgeFrames { alpicoolState.apply(frame) }
|
||||
alpicoolState.zoneMode = fridgeZoneMode
|
||||
onFridgeState?(alpicoolState)
|
||||
publish(alpicoolState.snapshot(deviceID: deviceID, rssi: nil),
|
||||
usable: alpicoolState.hasStatus)
|
||||
return
|
||||
// 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
|
||||
}
|
||||
|
||||
let (wattFrames, wattRemainder) = WattCycleProtocol.extractFrames(from: buffer)
|
||||
if !wattFrames.isEmpty {
|
||||
buffer = wattRemainder
|
||||
adopt(.wattCycle)
|
||||
for frame in wattFrames { wattCycleState.apply(frame) }
|
||||
publish(wattCycleState.snapshot(deviceID: deviceID, rssi: nil),
|
||||
usable: wattCycleState.hasUsableData)
|
||||
return
|
||||
}
|
||||
if consumeAlpicool() { return }
|
||||
if consumeWattCycle() { return }
|
||||
if consumeJBD() { return }
|
||||
if consumeDalyModbus() { return }
|
||||
if consumeDalyClassic() { return }
|
||||
|
||||
// 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
|
||||
adopt(.jbd)
|
||||
for frame in jbdFrames { jbdState.apply(frame) }
|
||||
publish(jbdState.snapshot(deviceID: deviceID, rssi: nil), usable: jbdState.hasUsableData)
|
||||
return
|
||||
}
|
||||
|
||||
if let start = buffer.firstIndex(where: { $0 == 0xD2 }),
|
||||
let registers = DalyProtocol.parseModbusResponse(Array(buffer[start...])) {
|
||||
buffer.removeAll()
|
||||
adopt(.dalyModbus)
|
||||
dalyState.apply(registers: registers)
|
||||
publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData)
|
||||
return
|
||||
}
|
||||
|
||||
let (dalyFrames, dalyRemainder) = DalyProtocol.extractA5Frames(from: buffer)
|
||||
if !dalyFrames.isEmpty {
|
||||
buffer = dalyRemainder
|
||||
adopt(.dalyClassic)
|
||||
for frame in dalyFrames { 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.
|
||||
// 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)
|
||||
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 true
|
||||
}
|
||||
|
||||
@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 frames { wattCycleState.apply(frame) }
|
||||
publish(wattCycleState.snapshot(deviceID: deviceID, rssi: nil),
|
||||
usable: wattCycleState.hasUsableData)
|
||||
return true
|
||||
}
|
||||
|
||||
/// 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 frames { jbdState.apply(frame) }
|
||||
publish(jbdState.snapshot(deviceID: deviceID, rssi: nil), usable: jbdState.hasUsableData)
|
||||
return true
|
||||
}
|
||||
|
||||
@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 true
|
||||
}
|
||||
|
||||
@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 frames { dalyState.apply(frame) }
|
||||
publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Erster verwertbarer Rahmen: Kandidat und Dialekt stehen fest, ab jetzt
|
||||
/// im normalen Takt abfragen.
|
||||
private func adopt(_ newDialect: Dialect) {
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||||
.frame(maxWidth: 320)
|
||||
.padding(.top, 8)
|
||||
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)
|
||||
case .vehicle:
|
||||
VehicleTiltView(pitch: state.pitch, roll: state.roll)
|
||||
}
|
||||
|
||||
adviceBanner
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||||
.frame(maxHeight: 220)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 geht’s") {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 |
@@ -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
@@ -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,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
|
||||
```
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user